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

RonasIT / laravel-swagger / 19128102867

06 Nov 2025 07:26AM UTC coverage: 99.311% (-0.3%) from 99.647%
19128102867

Pull #174

github

web-flow
Merge 229e54592 into 4ec8d4d34
Pull Request #174: 163 modify 500 code error response page

26 of 26 new or added lines in 5 files covered. (100.0%)

3 existing lines in 3 files now uncovered.

865 of 871 relevant lines covered (99.31%)

21.5 hits per line

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

99.52
/src/Validators/SwaggerSpecValidator.php
1
<?php
2

3
namespace RonasIT\AutoDoc\Validators;
4

5
use Illuminate\Support\Arr;
6
use Illuminate\Support\Str;
7
use RonasIT\AutoDoc\Exceptions\SpecValidation\DuplicateFieldException;
8
use RonasIT\AutoDoc\Exceptions\SpecValidation\DuplicateParamException;
9
use RonasIT\AutoDoc\Exceptions\SpecValidation\DuplicatePathPlaceholderException;
10
use RonasIT\AutoDoc\Exceptions\SpecValidation\InvalidPathException;
11
use RonasIT\AutoDoc\Exceptions\SpecValidation\InvalidFieldValueException;
12
use RonasIT\AutoDoc\Exceptions\SpecValidation\InvalidStatusCodeException;
13
use RonasIT\AutoDoc\Exceptions\SpecValidation\InvalidSwaggerSpecException;
14
use RonasIT\AutoDoc\Exceptions\SpecValidation\InvalidSwaggerVersionException;
15
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingExternalRefException;
16
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingLocalRefException;
17
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingFieldException;
18
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingPathParamException;
19
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingPathPlaceholderException;
20
use RonasIT\AutoDoc\Exceptions\SpecValidation\MissingRefFileException;
21
use RonasIT\AutoDoc\Services\SwaggerService;
22

23
/**
24
 * @property array $doc
25
 */
26
class SwaggerSpecValidator
27
{
28
    public const SCHEMA_TYPES = [
29
        'array',
30
        'boolean',
31
        'integer',
32
        'number',
33
        'string',
34
        'object',
35
        'null',
36
        'undefined',
37
    ];
38

39
    public const PRIMITIVE_TYPES = [
40
        'array',
41
        'boolean',
42
        'integer',
43
        'number',
44
        'string',
45
        'object',
46
        'date',
47
        'double',
48
    ];
49

50
    public const REQUIRED_FIELDS = [
51
        'components' => ['type'],
52
        'doc' => ['openapi', 'info', 'paths'],
53
        'info' => ['title', 'version'],
54
        'item' => ['type'],
55
        'header' => ['type'],
56
        'operation' => ['responses'],
57
        'parameter' => ['in', 'name'],
58
        'requestBody' => ['content'],
59
        'response' => ['description'],
60
        'security_definition' => ['type'],
61
        'tag' => ['name'],
62
    ];
63

64
    public const ALLOWED_VALUES = [
65
        'parameter_collection_format' => ['csv', 'ssv', 'tsv', 'pipes', 'multi'],
66
        'items_collection_format' => ['csv', 'ssv', 'tsv', 'pipes'],
67
        'header_collection_format' => ['csv', 'ssv', 'tsv', 'pipes'],
68
        'parameter_in' => ['body', 'formData', 'query', 'path', 'header'],
69
        'schemes' => ['http', 'https', 'ws', 'wss'],
70
        'security_definition_flow' => ['implicit', 'password', 'application', 'accessCode'],
71
        'security_definition_in' => ['query', 'header'],
72
        'security_definition_type' => ['basic', 'apiKey', 'oauth2'],
73
    ];
74

75
    public const ALLOWED_TYPES = [
76
        self::MIME_TYPE_APPLICATION_URLENCODED,
77
        self::MIME_TYPE_MULTIPART_FORM_DATA,
78
        self::MIME_TYPE_APPLICATION_JSON,
79
    ];
80

81
    public const PATH_PARAM_REGEXP = '#(?<={)[^/}]+(?=})#';
82
    public const PATH_REGEXP = '/^x-/';
83

84
    public const MIME_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
85
    public const MIME_TYPE_APPLICATION_URLENCODED = 'application/x-www-form-urlencoded';
86
    public const MIME_TYPE_APPLICATION_JSON = 'application/json';
87

88
    protected $doc;
89

90
    public function validate(array $doc): void
91
    {
92
        $this->doc = $doc;
43✔
93

94
        $this->validateVersion();
43✔
95
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['doc']);
42✔
96
        $this->validateInfo();
41✔
97
        $this->validateSchemes();
40✔
98
        $this->validatePaths();
40✔
99
        $this->validateDefinitions();
15✔
100
        $this->validateSecurityDefinitions();
14✔
101
        $this->validateTags();
11✔
102
        $this->validateRefs();
9✔
103
    }
104

105
    protected function validateVersion(): void
106
    {
107
        $version = Arr::get($this->doc, 'openapi', '');
43✔
108

109
        if (version_compare($version, SwaggerService::OPEN_API_VERSION, '!=')) {
43✔
110
            throw new InvalidSwaggerVersionException($version);
1✔
111
        }
112
    }
113

114
    protected function validateInfo(): void
115
    {
116
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['info'], 'info');
41✔
117
    }
118

119
    protected function validateSchemes(): void
120
    {
121
        $this->validateFieldValue('schemes', self::ALLOWED_VALUES['schemes']);
40✔
122
    }
123

124
    protected function validatePaths(): void
125
    {
126
        foreach ($this->doc['paths'] as $path => $operations) {
40✔
127
            if (!Str::startsWith($path, '/') && !preg_match(self::PATH_REGEXP, $path)) {
39✔
128
                throw new InvalidPathException("paths.{$path}");
1✔
129
            }
130

131
            foreach ($operations as $pathKey => $operation) {
38✔
132
                $operationId = "paths.{$path}.{$pathKey}";
38✔
133

134
                $this->validateFieldsPresent(self::REQUIRED_FIELDS['operation'], $operationId);
38✔
135
                $this->validateFieldValue("{$operationId}.schemes", self::ALLOWED_VALUES['schemes']);
37✔
136

137
                $this->validateParameters($operation, $path, $operationId);
37✔
138

139
                if (!empty($operation['requestBody'])) {
25✔
140
                    $this->validateRequestBody($operation, $operationId);
10✔
141
                }
142

143
                foreach ($operation['responses'] as $statusCode => $response) {
24✔
144
                    $this->validateResponse($response, $statusCode, $operationId);
24✔
145
                }
146
            }
147
        }
148

149
        $this->validateOperationIdsUnique();
16✔
150
    }
151

152
    protected function validateDefinitions(): void
153
    {
154
        $definitions = Arr::get($this->doc, 'components.schemas', []);
15✔
155

156
        foreach ($definitions as $index => $definition) {
15✔
157
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['components'], "components.schemas.{$index}");
7✔
158
        }
159
    }
160

161
    protected function validateSecurityDefinitions(): void
162
    {
163
        $securityDefinitions = Arr::get($this->doc, 'securityDefinitions', []);
14✔
164

165
        foreach ($securityDefinitions as $index => $securityDefinition) {
14✔
166
            $parentId = "securityDefinitions.{$index}";
3✔
167

168
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['security_definition'], $parentId);
3✔
169

170
            $this->validateFieldValue("{$parentId}.type", self::ALLOWED_VALUES['security_definition_type']);
3✔
171
            $this->validateFieldValue("{$parentId}.in", self::ALLOWED_VALUES['security_definition_in']);
2✔
172
            $this->validateFieldValue("{$parentId}.flow", self::ALLOWED_VALUES['security_definition_flow']);
1✔
173
        }
174
    }
175

176
    protected function validateTags(): void
177
    {
178
        $tags = Arr::get($this->doc, 'tags', []);
11✔
179

180
        foreach ($tags as $index => $tag) {
11✔
181
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['tag'], "tags.{$index}");
2✔
182
        }
183

184
        $this->validateTagsUnique();
10✔
185
    }
186

187
    protected function validateResponse(array $response, string $statusCode, string $operationId): void
188
    {
189
        $responseId = "{$operationId}.responses.{$statusCode}";
24✔
190

191
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['response'], $responseId);
24✔
192

193
        if (
194
            ($statusCode !== 'default')
23✔
195
            && !$this->isValidStatusCode($statusCode)
23✔
196
            && !preg_match(self::PATH_REGEXP, $statusCode)
23✔
197
        ) {
198
            throw new InvalidStatusCodeException($responseId);
1✔
199
        }
200

201
        foreach (Arr::get($response, 'headers', []) as $headerName => $header) {
22✔
202
            $this->validateHeader($header, "{$responseId}.headers.{$headerName}");
3✔
203
        }
204

205
        if (!empty($response['schema'])) {
20✔
206
            $this->validateType(
10✔
207
                $response['schema'],
10✔
208
                array_merge(self::SCHEMA_TYPES, ['file']),
10✔
209
                "{$responseId}.schema"
10✔
210
            );
10✔
211

212
            if (!empty($response['schema']['items'])) {
8✔
213
                $this->validateItems($response['schema']['items'], "{$responseId}.schema.items");
1✔
214
            }
215
        }
216
    }
217

218
    protected function validateParameters(array $operation, string $path, string $operationId): void
219
    {
220
        $parameters = Arr::get($operation, 'parameters', []);
37✔
221

222
        foreach ($parameters as $index => $param) {
37✔
223
            $paramId = "{$operationId}.parameters.{$index}";
25✔
224

225
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['parameter'], $paramId);
25✔
226

227
            $this->validateFieldValue("{$paramId}.in", self::ALLOWED_VALUES['parameter_in']);
24✔
228
            $this->validateFieldValue(
23✔
229
                "{$paramId}.collectionFormat",
23✔
230
                self::ALLOWED_VALUES['parameter_collection_format']
23✔
231
            );
23✔
232

233
            $this->validateParameterType($param, $operation, $paramId, $operationId);
23✔
234

235
            if (!empty($param['schema']['items'])) {
22✔
236
                $this->validateItems($param['schema']['items'], "{$paramId}.schema.items");
1✔
237
            }
238
        }
239

240
        $this->validateParamsUnique($parameters, $operationId);
31✔
241
        $this->validatePathParameters($parameters, $path, $operationId);
29✔
242
        $this->validateBodyParameters($parameters, $operationId);
26✔
243
    }
244

245
    protected function validateRequestBody(array $operation, string $operationId): void
246
    {
247
        $requestBody = Arr::get($operation, 'requestBody', []);
10✔
248

249
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['requestBody'], "{$operationId}.requestBody");
10✔
250

251
        $this->validateRequestBodyContent($requestBody['content'], $operationId);
10✔
252
    }
253

254
    protected function validateRequestBodyContent(array $content, string $operationId): void
255
    {
256
        $invalidContentTypes = array_diff(array_keys($content), self::ALLOWED_TYPES);
10✔
257

258
        if (!empty($invalidContentTypes)) {
10✔
259
            $invalidTypes = implode(', ', $invalidContentTypes);
1✔
260

261
            throw new InvalidSwaggerSpecException(
1✔
262
                "Operation '{$operationId}' has invalid content types: {$invalidTypes}."
1✔
263
            );
1✔
264
        }
265
    }
266

267
    protected function validateType(array $schema, array $validTypes, string $schemaId): void
268
    {
269
        $schemaType = Arr::get($schema, 'type');
27✔
270

271
        if (!empty($schemaType) && !in_array($schemaType, $validTypes)) {
27✔
272
            throw new InvalidFieldValueException("{$schemaId}.type", $validTypes, [$schema['type']]);
1✔
273
        }
274

275
        if (($schemaType === 'array') && empty($schema['items'])) {
27✔
276
            throw new InvalidSwaggerSpecException("{$schemaId} is an array, so it must include an 'items' field.");
3✔
277
        }
278
    }
279

280
    protected function validatePathParameters(array $params, string $path, string $operationId): void
281
    {
282
        $pathParams = Arr::where($params, function ($param) {
29✔
283
            return $param['in'] === 'path';
17✔
284
        });
29✔
285

286
        preg_match_all(self::PATH_PARAM_REGEXP, $path, $matches);
29✔
287
        $placeholders = Arr::first($matches);
29✔
288

289
        $placeholderDuplicates = $this->getArrayDuplicates($placeholders);
29✔
290

291
        if (!empty($placeholderDuplicates)) {
29✔
292
            throw new DuplicatePathPlaceholderException($placeholderDuplicates, $path);
1✔
293
        }
294

295
        $missedRequiredParams = array_filter($pathParams, function ($param) use ($placeholders) {
28✔
296
            return Arr::get($param, 'required', false) && !in_array(Arr::get($param, 'name'), $placeholders);
7✔
297
        });
28✔
298

299
        if (!empty($missedRequiredParams)) {
28✔
300
            $missedRequiredString = implode(',', Arr::pluck($missedRequiredParams, 'name'));
1✔
301

302
            throw new InvalidSwaggerSpecException(
1✔
303
                "Path parameters cannot be optional. Set required=true for the "
1✔
304
                . "'{$missedRequiredString}' parameters at operation '{$operationId}'."
1✔
305
            );
1✔
306
        }
307

308
        $missingPlaceholders = array_diff(Arr::pluck($pathParams, 'name'), $placeholders);
27✔
309

310
        if (!empty($missingPlaceholders)) {
27✔
311
            throw new MissingPathPlaceholderException($operationId, $missingPlaceholders);
1✔
312
        }
313

314
        $missingPathParams = array_diff($placeholders, Arr::pluck($pathParams, 'name'));
27✔
315

316
        if (!empty($missingPathParams)) {
27✔
317
            throw new MissingPathParamException($operationId, $missingPathParams);
1✔
318
        }
319
    }
320

321
    protected function validateBodyParameters(array $parameters, string $operationId): void
322
    {
323
        $bodyParamsCount = collect($parameters)->where('in', 'body')->count();
26✔
324
        $formParamsCount = collect($parameters)->where('in', 'formData')->count();
26✔
325

326
        if ($bodyParamsCount > 1) {
26✔
327
            throw new InvalidSwaggerSpecException(
1✔
328
                "Operation '{$operationId}' has {$bodyParamsCount} body parameters. Only one is allowed."
1✔
329
            );
1✔
330
        }
331

332
        if ($bodyParamsCount && $formParamsCount) {
25✔
333
            throw new InvalidSwaggerSpecException(
1✔
334
                "Operation '{$operationId}' has body and formData parameters. Only one or the other is allowed."
1✔
335
            );
1✔
336
        }
337
    }
338

339
    protected function validateParameterType(array $param, array $operation, string $paramId, string $operationId): void
340
    {
341
        switch ($param['in']) {
23✔
342
            case 'body':
23✔
343
                $requiredFields = ['schema'];
15✔
344
                $validTypes = self::SCHEMA_TYPES;
15✔
345

346
                break;
15✔
347
            case 'formData':
15✔
348
                $this->validateFormDataConsumes($operation, $operationId);
3✔
349

350
                $requiredFields = ['schema'];
1✔
351
                $validTypes = array_merge(self::PRIMITIVE_TYPES, ['file']);
1✔
352

353
                break;
1✔
354
            default:
355
                $requiredFields = ['schema'];
15✔
356
                $validTypes = self::PRIMITIVE_TYPES;
15✔
357
        }
358

359
        $this->validateFieldsPresent($requiredFields, $paramId);
23✔
360

361
        $schema = Arr::get($param, 'schema', $param);
23✔
362
        $this->validateType($schema, $validTypes, $paramId);
23✔
363
    }
364

365
    protected function validateHeader(array $header, string $headerId): void
366
    {
367
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['header'], $headerId);
3✔
368
        $this->validateType($header, self::PRIMITIVE_TYPES, $headerId);
2✔
369
        $this->validateFieldValue("{$headerId}.collectionFormat", self::ALLOWED_VALUES['header_collection_format']);
2✔
370

371
        if (!empty($header['items'])) {
2✔
372
            $this->validateItems($header['items'], $headerId);
1✔
373
        }
374
    }
375

376
    protected function validateItems(array $items, string $itemsId): void
377
    {
378
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['item'], $itemsId);
3✔
379
        $this->validateType($items, self::PRIMITIVE_TYPES, $itemsId);
1✔
380
        $this->validateFieldValue("{$itemsId}.collectionFormat", self::ALLOWED_VALUES['items_collection_format']);
1✔
381
    }
382

383
    protected function getMissingFields(array $requiredFields, array $doc, ?string $fieldName = null): array
384
    {
385
        return array_diff($requiredFields, array_keys(Arr::get($doc, $fieldName)));
42✔
386
    }
387

388
    protected function validateFieldsPresent(array $requiredFields, ?string $fieldName = null): void
389
    {
390
        $missingDocFields = $this->getMissingFields($requiredFields, $this->doc, $fieldName);
42✔
391

392
        if (!empty($missingDocFields)) {
42✔
393
            throw new MissingFieldException($missingDocFields, $fieldName);
11✔
394
        }
395
    }
396

397
    protected function validateFieldValue(string $fieldName, array $allowedValues): void
398
    {
399
        $inputValue = Arr::wrap(Arr::get($this->doc, $fieldName, []));
40✔
400
        $approvedValues = array_intersect($inputValue, $allowedValues);
40✔
401
        $invalidValues = array_diff($inputValue, $approvedValues);
40✔
402

403
        if (!empty($invalidValues)) {
40✔
404
            throw new InvalidFieldValueException($fieldName, $allowedValues, $invalidValues);
4✔
405
        }
406
    }
407

408
    protected function validateRefs(): void
409
    {
410
        array_walk_recursive($this->doc, function ($item, $key) {
9✔
411
            if ($key === '$ref') {
9✔
412
                $refParts = explode('#/', $item);
9✔
413
                $refFilename = Arr::first($refParts);
9✔
414

415
                if (count($refParts) > 1) {
9✔
416
                    $path = pathinfo(Arr::last($refParts));
7✔
417
                    $refParentKey = $path['dirname'];
7✔
418
                    $refKey = $path['filename'];
7✔
419
                }
420

421
                if (!empty($refFilename) && !file_exists($refFilename)) {
9✔
422
                    throw new MissingRefFileException($refFilename);
2✔
423
                }
424

425
                $missingRefs = $this->getMissingFields(
7✔
426
                    (array) $refKey,
7✔
427
                    !empty($refFilename)
7✔
428
                        ? json_decode(file_get_contents($refFilename), true)
1✔
429
                        : $this->doc,
7✔
430
                    str_replace('/', '.', $refParentKey),
7✔
431
                );
7✔
432

433
                if (!empty($missingRefs)) {
7✔
434
                    if (!empty($refFilename)) {
1✔
435
                        throw new MissingExternalRefException($refKey, $refFilename);
1✔
436
                    } else {
UNCOV
437
                        throw new MissingLocalRefException($refKey, $refParentKey);
×
438
                    }
439
                }
440
            }
441
        });
9✔
442
    }
443

444
    protected function validateParamsUnique(array $params, string $operationId): void
445
    {
446
        $collection = collect($params);
31✔
447
        $duplicates = $collection->duplicates(function ($item) {
31✔
448
            return $item['in'] . $item['name'];
19✔
449
        });
31✔
450

451
        if ($duplicates->count()) {
31✔
452
            $duplicateIndex = $duplicates->keys()->first();
2✔
453

454
            throw new DuplicateParamException($params[$duplicateIndex]['in'], $params[$duplicateIndex]['name'], $operationId);
2✔
455
        }
456
    }
457

458
    protected function validateTagsUnique(): void
459
    {
460
        $tags = Arr::get($this->doc, 'tags', []);
10✔
461
        $tagNames = Arr::pluck($tags, 'name');
10✔
462
        $duplicates = $this->getArrayDuplicates($tagNames);
10✔
463

464
        if (!empty($duplicates)) {
10✔
465
            throw new DuplicateFieldException('tags.*.name', $duplicates);
1✔
466
        }
467
    }
468

469
    protected function validateOperationIdsUnique(): void
470
    {
471
        $operationIds = Arr::pluck(Arr::get($this->doc, 'paths', []), '*.operationId');
16✔
472
        $operationIds = Arr::flatten($operationIds);
16✔
473
        $duplicates = $this->getArrayDuplicates($operationIds);
16✔
474

475
        if (!empty($duplicates)) {
16✔
476
            throw new DuplicateFieldException('paths.*.*.operationId', $duplicates);
1✔
477
        }
478
    }
479

480
    protected function validateFormDataConsumes(array $operation, string $operationId): void
481
    {
482
        $consumes = Arr::get($operation, 'consumes', []);
3✔
483

484
        $requiredConsume = Arr::first($consumes, function ($consume) {
3✔
485
            return in_array($consume, [
2✔
486
                self::MIME_TYPE_APPLICATION_URLENCODED,
2✔
487
                self::MIME_TYPE_MULTIPART_FORM_DATA,
2✔
488
            ]);
2✔
489
        });
3✔
490

491
        if (empty($requiredConsume)) {
3✔
492
            throw new InvalidSwaggerSpecException(
2✔
493
                "Operation '{$operationId}' has body and formData parameters. Only one or the other is allowed."
2✔
494
            );
2✔
495
        }
496
    }
497

498
    protected function getArrayDuplicates(array $array): array
499
    {
500
        $array = array_filter($array);
30✔
501
        $duplicates = array_filter(array_count_values($array), function ($value) {
30✔
502
            return $value > 1;
9✔
503
        });
30✔
504

505
        return array_keys($duplicates);
30✔
506
    }
507

508
    protected function isValidStatusCode(string $code): bool
509
    {
510
        $code = intval($code);
19✔
511

512
        return $code >= 100 && $code < 600;
19✔
513
    }
514
}
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