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

onmoon / openapi-server-bundle / 15887241442

25 Jun 2025 09:09PM UTC coverage: 81.18% (+0.09%) from 81.095%
15887241442

push

github

web-flow
Fix minimum versions install (#196) (#197)

* Fix minimum versions install (#196)

* Update dependencies (#195)

* update dependencies

* update sspat/reserved-words in order to use last thecodingmachine/safe

* update cebe/php-openapi

* remove usage of Safe/sprintf, Safe/substr

* fixing phpstan errors

* fixing phpstan errors

* fixing psalm errors

* tests

* tests

* tests

* downgrade cebe/php-openapi

* Revert "downgrade cebe/php-openapi"

This reverts commit ca5992ad0.

* Add PHP 8.3 and 8.4 support and update dependencies

* add security scheme

* Revert "add security scheme"

This reverts commit a1772c9c5.

* update devizzent/cebe-php-openapi

* csfix

---------

Co-authored-by: innerfly <you@example.com>

* fix

* fix errors

* fix cs

* fix package min version

* fix min package version

* fix min ver

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* remove override attr

* fix

---------

Co-authored-by: Vladimir <v.sukhoff@gmail.com>
Co-authored-by: innerfly <you@example.com>
Co-authored-by: Patrik Foldes <pf@csgo.com>

* fix code coverage regression

* update readme

---------

Co-authored-by: Vladimir <v.sukhoff@gmail.com>
Co-authored-by: innerfly <you@example.com>
Co-authored-by: Patrik Foldes <pf@csgo.com>

18 of 22 new or added lines in 5 files covered. (81.82%)

1 existing line in 1 file now uncovered.

1376 of 1695 relevant lines covered (81.18%)

3.83 hits per line

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

92.75
/src/Specification/SpecificationParser.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace OnMoon\OpenApiServerBundle\Specification;
6

7
use cebe\openapi\spec\MediaType;
8
use cebe\openapi\spec\OpenApi;
9
use cebe\openapi\spec\Operation;
10
use cebe\openapi\spec\Parameter;
11
use cebe\openapi\spec\PathItem;
12
use cebe\openapi\spec\Reference;
13
use cebe\openapi\spec\RequestBody;
14
use cebe\openapi\spec\Response;
15
use cebe\openapi\spec\Responses;
16
use cebe\openapi\spec\Schema;
17
use cebe\openapi\spec\Type;
18
use DateTimeInterface;
19
use OnMoon\OpenApiServerBundle\Exception\CannotParseOpenApi;
20
use OnMoon\OpenApiServerBundle\Specification\Definitions\ComponentArray;
21
use OnMoon\OpenApiServerBundle\Specification\Definitions\ObjectReference;
22
use OnMoon\OpenApiServerBundle\Specification\Definitions\ObjectSchema;
23
use OnMoon\OpenApiServerBundle\Specification\Definitions\Operation as OperationDefinition;
24
use OnMoon\OpenApiServerBundle\Specification\Definitions\Property as PropertyDefinition;
25
use OnMoon\OpenApiServerBundle\Specification\Definitions\Specification;
26
use OnMoon\OpenApiServerBundle\Specification\Definitions\SpecificationConfig;
27
use OnMoon\OpenApiServerBundle\Types\ScalarTypesResolver;
28
use Safe\DateTime;
29

30
use function array_filter;
31
use function array_key_exists;
32
use function array_map;
33
use function array_merge;
34
use function class_exists;
35
use function count;
36
use function in_array;
37
use function is_a;
38
use function is_array;
39
use function is_int;
40
use function Safe\preg_match;
41
use function str_ends_with;
42
use function strcasecmp;
43
use function substr;
44

45
/** @psalm-suppress ClassMustBeFinal */
46
class SpecificationParser
47
{
48
    private ScalarTypesResolver $typeResolver;
49
    /** @var string[] */
50
    private array $skipHttpCodes;
51
    private ?string $dateTimeClass = null;
52

53
    /** @param array<array-key, string|int> $skipHttpCodes */
54
    public function __construct(ScalarTypesResolver $typeResolver, array $skipHttpCodes)
55
    {
56
        $this->typeResolver  = $typeResolver;
19✔
57
        $this->skipHttpCodes = array_map(static fn ($code) => (string) $code, $skipHttpCodes);
19✔
58
    }
59

60
    public function parseOpenApi(string $specificationName, SpecificationConfig $specificationConfig, OpenApi $parsedSpecification): Specification
61
    {
62
        $componentSchemas = new ComponentArray();
19✔
63

64
        $operationDefinitions = [];
19✔
65

66
        $this->dateTimeClass = $specificationConfig->getDateTimeClass();
19✔
67

68
        /** @var string $url */
69
        foreach ($parsedSpecification->paths as $url => $pathItem) {
19✔
70
            /** @var string $method */
71
            foreach ($pathItem->getOperations() as $method => $operation) {
19✔
72
                $operationId = $operation->operationId;
19✔
73
                $summary     = $operation->summary;
19✔
74

75
                $exceptionContext = [
19✔
76
                    'url' => $url,
19✔
77
                    'method' => $method,
19✔
78
                    'path' => $specificationConfig->getPath(),
19✔
79
                ];
19✔
80

81
                if ($operationId === '') {
19✔
82
                    throw CannotParseOpenApi::becauseNoOperationIdSpecified($exceptionContext);
1✔
83
                }
84

85
                if (array_key_exists($operationId, $operationDefinitions)) {
18✔
86
                    throw CannotParseOpenApi::becauseDuplicateOperationId($operationId, $exceptionContext);
1✔
87
                }
88

89
                $responses = $this->getResponseDtoDefinitions($operation->responses, $specificationConfig, $componentSchemas, $exceptionContext);
18✔
90

91
                $requestSchema = $this->findByMediaType($operation->requestBody, $specificationConfig->getMediaType());
12✔
92
                $requestBody   = null;
12✔
93

94
                if ($requestSchema !== null) {
12✔
95
                    $requestBody = $this->getObjectSchema(
4✔
96
                        $requestSchema,
4✔
97
                        true,
4✔
98
                        $componentSchemas,
4✔
99
                        $exceptionContext + ['location' => 'request body']
4✔
100
                    );
4✔
101
                }
102

103
                $parameters        = $this->mergeParameters($pathItem, $operation);
11✔
104
                $requestParameters = [];
11✔
105

106
                foreach (['path', 'query'] as $in) {
11✔
107
                    $params = $this->parseParameters($in, $parameters, $componentSchemas, $exceptionContext + ['location' => 'request ' . $in . ' parameters']);
11✔
108

109
                    if ($params === null) {
11✔
110
                        continue;
10✔
111
                    }
112

113
                    $requestParameters[$in] = $params;
1✔
114
                }
115

116
                $handlerName = $specificationName . '.' . $operationId;
6✔
117

118
                $operationDefinitions[$operationId] = new OperationDefinition(
6✔
119
                    $url,
6✔
120
                    $method,
6✔
121
                    $handlerName,
6✔
122
                    $summary,
6✔
123
                    $requestBody,
6✔
124
                    $requestParameters,
6✔
125
                    $responses
6✔
126
                );
6✔
127
            }
128
        }
129

130
        return new Specification($operationDefinitions, $componentSchemas->getArrayCopy(), $parsedSpecification);
5✔
131
    }
132

133
    /**
134
     * @param Response[]|Responses|null                                    $responses
135
     * @param array{location?:string,method:string,url:string,path:string} $exceptionContext
136
     *
137
     * @return array<string|int,ObjectSchema|ObjectReference>
138
     */
139
    private function getResponseDtoDefinitions(
140
        array|Responses|null $responses,
141
        SpecificationConfig $specificationConfig,
142
        ComponentArray $componentSchemas,
143
        array $exceptionContext
144
    ): array {
145
        $responseDefinitions = [];
18✔
146

147
        if ($responses === null) {
18✔
148
            return [];
9✔
149
        }
150

151
        if ($responses instanceof Responses) {
9✔
152
            $responses = $responses->getResponses();
9✔
153
        }
154

155
        foreach ($responses as $responseCode => $response) {
9✔
156
            if ($this->isHttpCodeSkipped((string) $responseCode)) {
9✔
157
                continue;
×
158
            }
159

160
            $responseSchema = $this->findByMediaType($response, $specificationConfig->getMediaType());
9✔
161

162
            if ($responseSchema === null) {
9✔
163
                $responseDefinitions[$responseCode] = new ObjectSchema([]);
1✔
164
            } else {
165
                $responseDefinitions[$responseCode] = $this->getObjectSchema(
9✔
166
                    $responseSchema,
9✔
167
                    false,
9✔
168
                    $componentSchemas,
9✔
169
                    $exceptionContext + ['location' => 'response (code "' . (string) $responseCode . '")']
9✔
170
                );
9✔
171
            }
172
        }
173

174
        return $responseDefinitions;
3✔
175
    }
176

177
    private function isHttpCodeSkipped(string $code): bool
178
    {
179
        foreach ($this->skipHttpCodes as $skippedCode) {
9✔
180
            if (strcasecmp($skippedCode, $code) === 0) {
×
181
                return true;
×
182
            }
183

184
            if (
185
                str_ends_with($skippedCode, '**') &&
×
186
                strcasecmp(
×
187
                    substr($skippedCode, 0, -2),
×
188
                    substr($code, 0, -2)
×
189
                ) === 0
×
190
            ) {
191
                return true;
×
192
            }
193
        }
194

195
        return false;
9✔
196
    }
197

198
    private function findByMediaType(Response|RequestBody|Reference|null $body, string $mediaType): ?Schema
199
    {
200
        if ($body === null || $body instanceof Reference) {
18✔
201
            return null;
7✔
202
        }
203

204
        foreach ($body->content as $type => $data) {
12✔
205
            if ($type !== $mediaType || ! ($data instanceof MediaType)) {
12✔
206
                continue;
2✔
207
            }
208

209
            if ($data->schema instanceof Schema) {
11✔
210
                return $data->schema;
11✔
211
            }
212
        }
213

214
        return null;
2✔
215
    }
216

217
    /**
218
     * @param Parameter[]|Reference[] $parameters
219
     *
220
     * @return Parameter[]
221
     */
222
    private function filterParameters(array $parameters): array
223
    {
224
        return array_filter($parameters, static fn ($parameter): bool => $parameter instanceof Parameter);
11✔
225
    }
226

227
    /** @return Parameter[] */
228
    private function mergeParameters(PathItem $pathItem, Operation $operation): array
229
    {
230
        $operationParameters = $this->filterParameters($operation->parameters);
11✔
231

232
        return array_merge(
11✔
233
            array_filter(
11✔
234
                $this->filterParameters($pathItem->parameters),
11✔
235
                static function (Parameter $pathParameter) use ($operationParameters): bool {
11✔
236
                    return count(
1✔
237
                        array_filter(
1✔
238
                            $operationParameters,
1✔
239
                            static function (Parameter $operationParameter) use ($pathParameter): bool {
1✔
240
                                    return $operationParameter->name === $pathParameter->name &&
1✔
241
                                        $operationParameter->in === $pathParameter->in;
1✔
242
                            }
1✔
243
                        )
1✔
244
                    ) === 0;
1✔
245
                }
11✔
246
            ),
11✔
247
            $operationParameters
11✔
248
        );
11✔
249
    }
250

251
    /**
252
     * @param Parameter[] $parameters
253
     *
254
     * @return Parameter[]
255
     */
256
    private function filterSupportedParameters(string $in, array $parameters): array
257
    {
258
        return array_filter($parameters, static fn ($parameter): bool => $parameter->in === $in);
11✔
259
    }
260

261
    /**
262
     * @param Parameter[]                                                 $parameters
263
     * @param array{location:string,method:string,url:string,path:string} $exceptionContext
264
     */
265
    private function parseParameters(string $in, array $parameters, ComponentArray $componentSchemas, array $exceptionContext): ?ObjectSchema
266
    {
267
        $properties = array_map(
11✔
268
            fn (Parameter $p) => $this
11✔
269
                ->getProperty(
11✔
270
                    $p->name,
11✔
271
                    $p->schema,
11✔
272
                    // @codeCoverageIgnoreStart
273
                    true,
274
                    $componentSchemas,
275
                    // @codeCoverageIgnoreEnd
276
                    $exceptionContext,
11✔
277
                    false
11✔
278
                )
11✔
279
                ->setRequired($p->required)
11✔
280
                ->setDescription($p->description),
11✔
281
            $this->filterSupportedParameters($in, $parameters)
11✔
282
        );
11✔
283

284
        if (count($properties) === 0) {
11✔
285
            return null;
10✔
286
        }
287

288
        return new ObjectSchema($properties);
1✔
289
    }
290

291
    private function getComponentSchemaName(string $path): ?string
292
    {
293
        if (preg_match('#^/components/schemas/([^/]+)$#', $path, $match) === 1) {
9✔
294
            /** @psalm-suppress PossiblyNullArrayAccess */
295
            return $match[1];
×
296
        }
297

298
        return null;
9✔
299
    }
300

301
    /** @param array{location:string,method:string,url:string,path:string} $exceptionContext */
302
    private function getObjectSchema(Schema $schema, ?bool $isRequest, ComponentArray $componentSchemas, array $exceptionContext): ObjectSchema|ObjectReference
303
    {
304
        if ($schema->type !== Type::OBJECT) {
12✔
305
            throw CannotParseOpenApi::becauseRootIsNotObject(
3✔
306
                $exceptionContext,
3✔
307
                ($schema->type === Type::ARRAY)
3✔
308
            );
3✔
309
        }
310

311
        $componentName = $this->getComponentSchemaName($schema->getDocumentPosition()?->getPointer() ?? '');
9✔
312
        if ($componentName !== null && $componentSchemas->offsetExists($componentName)) {
9✔
313
            /** @phpstan-ignore-next-line */
314
            return new ObjectReference($componentName, $componentSchemas[$componentName]);
×
315
        }
316

317
        $propertyDefinitions = [];
9✔
318
        /** @var string $propertyName */
319
        foreach ($schema->properties as $propertyName => $property) {
9✔
320
            if (! ($property instanceof Schema)) {
7✔
321
                throw CannotParseOpenApi::becausePropertyIsNotScheme();
1✔
322
            }
323

324
            //ToDo: Rework this in components
325
            if (($property->readOnly && $isRequest === true) || ($property->writeOnly && $isRequest === false)) {
6✔
326
                continue;
1✔
327
            }
328

329
            /** @psalm-suppress RedundantConditionGivenDocblockType */
330
            $required              = is_array($schema->required) && in_array($propertyName, $schema->required, true);
6✔
331
            $propertyDefinitions[] = $this->getProperty($propertyName, $property, $isRequest, $componentSchemas, $exceptionContext)->setRequired($required);
6✔
332
        }
333

334
        $objectSchema = new ObjectSchema($propertyDefinitions);
5✔
335

336
        if ($componentName !== null) {
5✔
337
            $componentSchemas[$componentName] = $objectSchema;
×
338

339
            return new ObjectReference($componentName, $objectSchema);
×
340
        }
341

342
        return $objectSchema;
5✔
343
    }
344

345
    /** @param array{location:string,method:string,url:string,path:string} $exceptionContext */
346
    private function getProperty(
347
        string $propertyName,
348
        Schema|Reference|null $property,
349
        ?bool $isRequest,
350
        ComponentArray $componentSchemas,
351
        array $exceptionContext,
352
        bool $allowNonScalar = true
353
    ): PropertyDefinition {
354
        if (! ($property instanceof Schema)) {
11✔
355
            throw CannotParseOpenApi::becausePropertyIsNotScheme();
1✔
356
        }
357

358
        $propertyDefinition = new PropertyDefinition($propertyName);
10✔
359
        $propertyDefinition->setDescription($property->description);
10✔
360
        $propertyDefinition->setNullable($property->nullable);
10✔
361
        $propertyDefinition->setPattern($property->pattern);
10✔
362

363
        $scalarTypeId = null;
10✔
364
        $isScalar     = true;
10✔
365

366
        if ($property->type === Type::ARRAY) {
10✔
367
            if (! ($property->items instanceof Schema)) {
3✔
368
                throw CannotParseOpenApi::becauseArrayIsNotDescribed($propertyName, $exceptionContext);
1✔
369
            }
370

371
            $propertyDefinition->setArray(true);
2✔
372
            $itemProperty = $property->items;
2✔
373
            $isScalar     = false;
2✔
374
        } else {
375
            $itemProperty = $property;
8✔
376
        }
377

378
        $propertyType = $itemProperty->type;
9✔
379

380
        if (is_array($propertyType)) {
9✔
NEW
381
            throw CannotParseOpenApi::becauseOpenapi31TypesNotSupported($propertyName, $exceptionContext);
×
382
        }
383

384
        if (Type::isScalar($propertyType)) {
9✔
385
            $scalarTypeId = $this->typeResolver->findScalarType($propertyType, $itemProperty->format);
7✔
386
            $propertyDefinition->setScalarTypeId($scalarTypeId);
7✔
387

388
            if ($this->typeResolver->isDateTime($scalarTypeId) && $this->dateTimeClass !== null) {
7✔
389
                if (preg_match('/^\\\\/', $this->dateTimeClass) !== 1) {
4✔
390
                    throw CannotParseOpenApi::becauseNotFQCN($this->dateTimeClass);
1✔
391
                }
392

393
                if (! class_exists($this->dateTimeClass)) {
3✔
394
                    throw CannotParseOpenApi::becauseUnknownType($this->dateTimeClass);
1✔
395
                }
396

397
                if (is_a($this->dateTimeClass, DateTimeInterface::class, true) === false) {
2✔
398
                    throw CannotParseOpenApi::becauseTypeNotSupported(
1✔
399
                        $propertyName,
1✔
400
                        $this->dateTimeClass,
1✔
401
                        $exceptionContext
1✔
402
                    );
1✔
403
                }
404

405
                $propertyDefinition->setOutputType($this->dateTimeClass);
1✔
406
            }
407
        } elseif ($propertyType === Type::OBJECT) {
4✔
408
            $objectType = $this->getObjectSchema(
3✔
409
                $itemProperty,
3✔
410
                $isRequest,
3✔
411
                $componentSchemas,
3✔
412
                $exceptionContext
3✔
413
            );
3✔
414
            $propertyDefinition->setObjectTypeDefinition($objectType);
3✔
415
            $isScalar = false;
3✔
416
        } else {
417
            throw CannotParseOpenApi::becauseTypeNotSupported($propertyName, $propertyType, $exceptionContext);
1✔
418
        }
419

420
        /** @var string|int|float|bool|null $schemaDefaultValue */
421
        $schemaDefaultValue = $itemProperty->default;
5✔
422

423
        if (
424
            // @codeCoverageIgnoreStart
425
            $schemaDefaultValue !== null &&
426
            $isScalar &&
427
            $scalarTypeId !== null
428
            // @codeCoverageIgnoreEnd
429
        ) {
430
            if ($this->typeResolver->isDateTime($scalarTypeId)) {
3✔
431
                // Symfony Yaml parses fields that looks like datetime into unix timestamp
432
                // however leaves strings untouched. We need to make types more solid
433
                if (is_int($schemaDefaultValue)) {
2✔
434
                    $datetime = (new DateTime())->setTimestamp($schemaDefaultValue);
2✔
435
                    /** @var string $schemaDefaultValue */
436
                    $schemaDefaultValue = $this->typeResolver->convert(false, $scalarTypeId, $datetime);
2✔
437
                }
438
            }
439

440
            $propertyDefinition->setDefaultValue($schemaDefaultValue);
3✔
441
        }
442

443
        if (! $isScalar && ! $allowNonScalar) {
5✔
444
            throw CannotParseOpenApi::becauseOnlyScalarAreAllowed($propertyName, $exceptionContext);
2✔
445
        }
446

447
        return $propertyDefinition;
3✔
448
    }
449
}
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

© 2026 Coveralls, Inc