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

RonasIT / laravel-swagger / 6380932365

02 Oct 2023 01:42PM UTC coverage: 97.965% (+0.7%) from 97.228%
6380932365

push

github

web-flow
Merge pull request #82 from RonasIT/45_fix_exception_when_setting_invalid_additional_path

#45: Add swagger spec validator to validate temp and additional doc format

257 of 257 new or added lines in 16 files covered. (100.0%)

722 of 737 relevant lines covered (97.96%)

15.66 hits per line

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

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

3
namespace RonasIT\Support\AutoDoc\Validators;
4

5
use Illuminate\Support\Arr;
6
use Illuminate\Support\Str;
7
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\DuplicateFieldException;
8
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\DuplicateParamException;
9
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\DuplicatePathPlaceholderException;
10
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\InvalidPathException;
11
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\InvalidFieldValueException;
12
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\InvalidStatusCodeException;
13
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\InvalidSwaggerSpecException;
14
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\InvalidSwaggerVersionException;
15
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingExternalRefException;
16
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingLocalRefException;
17
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingFieldException;
18
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingPathParamException;
19
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingPathPlaceholderException;
20
use RonasIT\Support\AutoDoc\Exceptions\SpecValidation\MissingRefFileException;
21
use RonasIT\Support\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
    ];
46

47
    public const REQUIRED_FIELDS = [
48
        'definition' => ['type'],
49
        'doc' => ['swagger', 'info', 'paths'],
50
        'info' => ['title', 'version'],
51
        'item' => ['type'],
52
        'header' => ['type'],
53
        'operation' => ['responses'],
54
        'parameter' => ['in', 'name'],
55
        'response' => ['description'],
56
        'security_definition' => ['type'],
57
        'tag' => ['name']
58
    ];
59

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

71
    public const PATH_PARAM_REGEXP = '#(?<={)[^/}]+(?=})#';
72
    public const PATH_REGEXP = '/^x-/';
73

74
    public const MIME_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';
75
    public const MIME_TYPE_APPLICATION_URLENCODED = 'application/x-www-form-urlencoded';
76

77
    protected $doc;
78

79
    public function validate(array $doc): void
80
    {
81
        $this->doc = $doc;
54✔
82

83
        $this->validateVersion();
54✔
84
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['doc']);
53✔
85
        $this->validateInfo();
51✔
86
        $this->validateSchemes();
50✔
87
        $this->validatePaths();
50✔
88
        $this->validateDefinitions();
27✔
89
        $this->validateSecurityDefinitions();
26✔
90
        $this->validateTags();
26✔
91
        $this->validateRefs();
24✔
92
    }
93

94
    protected function validateVersion(): void
95
    {
96
        $version = Arr::get($this->doc, 'swagger', '');
54✔
97

98
        if (version_compare($version, SwaggerService::SWAGGER_VERSION, '!=')) {
54✔
99
            throw new InvalidSwaggerVersionException($version);
1✔
100
        }
101
    }
102

103
    protected function validateInfo(): void
104
    {
105
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['info'], 'info');
51✔
106
    }
107

108
    protected function validateSchemes(): void
109
    {
110
        $this->validateFieldValue('schemes', self::ALLOWED_VALUES['schemes']);
50✔
111
    }
112

113
    protected function validatePaths(): void
114
    {
115
        foreach ($this->doc['paths'] as $path => $operations) {
50✔
116
            if (!Str::startsWith($path, '/') && !preg_match(self::PATH_REGEXP, $path)) {
32✔
117
                throw new InvalidPathException("paths.{$path}");
1✔
118
            }
119

120
            foreach ($operations as $pathKey => $operation) {
31✔
121
                $operationId = "paths.{$path}.{$pathKey}";
31✔
122

123
                $this->validateFieldsPresent(self::REQUIRED_FIELDS['operation'], $operationId);
31✔
124
                $this->validateFieldValue("{$operationId}.schemes", self::ALLOWED_VALUES['schemes']);
30✔
125

126
                $this->validateParameters($operation, $path, $operationId);
30✔
127

128
                foreach ($operation['responses'] as $statusCode => $response) {
18✔
129
                    $this->validateResponse($response, $statusCode, $operationId);
18✔
130
                }
131
            }
132
        }
133

134
        $this->validateOperationIdsUnique();
28✔
135
    }
136

137
    protected function validateDefinitions(): void
138
    {
139
        $definitions = Arr::get($this->doc, 'definitions', []);
27✔
140

141
        foreach ($definitions as $index => $definition) {
27✔
142
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['definition'], "definitions.{$index}");
6✔
143
        }
144
    }
145

146
    protected function validateSecurityDefinitions(): void
147
    {
148
        $securityDefinitions = Arr::get($this->doc, 'securityDefinitions', []);
26✔
149

150
        foreach ($securityDefinitions as $index => $securityDefinition) {
26✔
151
            $parentId = "securityDefinitions.{$index}";
7✔
152

153
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['security_definition'], $parentId);
7✔
154

155
            $this->validateFieldValue("{$parentId}.'type", self::ALLOWED_VALUES['security_definition_type']);
7✔
156
            $this->validateFieldValue("{$parentId}.'in", self::ALLOWED_VALUES['security_definition_in']);
7✔
157
            $this->validateFieldValue("{$parentId}.'flow", self::ALLOWED_VALUES['security_definition_flow']);
7✔
158
        }
159
    }
160

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

165
        foreach ($tags as $index => $tag) {
26✔
166
            $this->validateFieldsPresent(self::REQUIRED_FIELDS['tag'], "tags.{$index}");
2✔
167
        }
168

169
        $this->validateTagsUnique();
25✔
170
    }
171

172
    protected function validateResponse(array $response, string $statusCode, string $operationId): void
173
    {
174
        $responseId = "{$operationId}.responses.{$statusCode}";
18✔
175

176
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['response'], $responseId);
18✔
177

178
        if (
179
            ($statusCode !== 'default')
17✔
180
            && !$this->isValidStatusCode($statusCode)
17✔
181
            && !preg_match(self::PATH_REGEXP, $statusCode)
17✔
182
        ) {
183
            throw new InvalidStatusCodeException($responseId);
1✔
184
        }
185

186
        foreach (Arr::get($response, 'headers', []) as $headerName => $header) {
16✔
187
            $this->validateHeader($header, "{$responseId}.headers.{$headerName}");
4✔
188
        }
189

190
        if (!empty($response['schema'])) {
14✔
191
            $this->validateType(
9✔
192
                $response['schema'],
9✔
193
                array_merge(self::SCHEMA_TYPES, ['file']),
9✔
194
                "{$responseId}.schema"
9✔
195
            );
9✔
196
        }
197

198
        if (!empty($response['items'])) {
12✔
199
            $this->validateItems($response['items'], "{$responseId}.items");
×
200
        }
201
    }
202

203
    protected function validateParameters(array $operation, string $path, string $operationId): void
204
    {
205
        $parameters = Arr::get($operation, 'parameters', []);
30✔
206

207
        foreach ($parameters as $index => $param) {
30✔
208
            $paramId = "{$operationId}.parameters.{$index}";
25✔
209

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

212
            $this->validateFieldValue("{$paramId}.in", self::ALLOWED_VALUES['parameter_in']);
24✔
213
            $this->validateFieldValue(
23✔
214
                "{$paramId}.collectionFormat",
23✔
215
                self::ALLOWED_VALUES['parameter_collection_format']
23✔
216
            );
23✔
217

218
            $this->validateParameterType($param, $operation, $paramId, $operationId);
23✔
219

220
            if (!empty($param['items'])) {
22✔
221
                $this->validateItems($param['items'], "{$paramId}.items");
1✔
222
            }
223
        }
224

225
        $this->validateParamsUnique($parameters, $operationId);
24✔
226
        $this->validatePathParameters($parameters, $path, $operationId);
22✔
227
        $this->validateBodyParameters($parameters, $operationId);
19✔
228
    }
229

230
    protected function validateType(array $schema, array $validTypes, string $schemaId): void
231
    {
232
        $schemaType = Arr::get($schema, 'type');
27✔
233

234
        if (!empty($schemaType) && !in_array($schemaType, $validTypes)) {
27✔
235
            throw new InvalidFieldValueException("{$schemaId}.type", $validTypes, [$schema['type']]);
1✔
236
        }
237

238
        if (($schemaType === 'array') && empty($schema['items'])) {
27✔
239
            throw new InvalidSwaggerSpecException("{$schemaId} is an array, so it must include an 'items' field.");
3✔
240
        }
241
    }
242

243
    protected function validatePathParameters(array $params, string $path, string $operationId): void
244
    {
245
        $pathParams = Arr::where($params, function ($param) {
22✔
246
            return $param['in'] === 'path';
17✔
247
        });
22✔
248

249
        preg_match_all(self::PATH_PARAM_REGEXP, $path, $matches);
22✔
250
        $placeholders = Arr::first($matches);
22✔
251

252
        $placeholderDuplicates = $this->getArrayDuplicates($placeholders);
22✔
253

254
        if (!empty($placeholderDuplicates)) {
22✔
255
            throw new DuplicatePathPlaceholderException($placeholderDuplicates, $path);
1✔
256
        }
257

258
        $missedRequiredParams = array_filter($pathParams, function ($param) use ($placeholders) {
21✔
259
            return Arr::get($param, 'required', false) && !in_array(Arr::get($param, 'name'), $placeholders);
7✔
260
        });
21✔
261

262
        if (!empty($missedRequiredParams)) {
21✔
263
            $missedRequiredString = implode(',', Arr::pluck($missedRequiredParams, 'name'));
1✔
264

265
            throw new InvalidSwaggerSpecException(
1✔
266
                "Path parameters cannot be optional. Set required=true for the "
1✔
267
                . "'{$missedRequiredString}' parameters at operation '{$operationId}'."
1✔
268
            );
1✔
269
        }
270

271
        $missingPlaceholders = array_diff(Arr::pluck($pathParams, 'name'), $placeholders);
20✔
272

273
        if (!empty($missingPlaceholders)) {
20✔
274
            throw new MissingPathPlaceholderException($operationId, $missingPlaceholders);
1✔
275
        }
276

277
        $missingPathParams = array_diff($placeholders, Arr::pluck($pathParams, 'name'));
20✔
278

279
        if (!empty($missingPathParams)) {
20✔
280
            throw new MissingPathParamException($operationId, $missingPathParams);
1✔
281
        }
282
    }
283

284
    protected function validateBodyParameters(array $parameters, string $operationId): void
285
    {
286
        $bodyParamsCount = collect($parameters)->where('in', 'body')->count();
19✔
287
        $formParamsCount = collect($parameters)->where('in', 'formData')->count();
19✔
288

289
        if ($bodyParamsCount > 1) {
19✔
290
            throw new InvalidSwaggerSpecException(
1✔
291
                "Operation '{$operationId}' has {$bodyParamsCount} body parameters. Only one is allowed."
1✔
292
            );
1✔
293
        }
294

295
        if ($bodyParamsCount && $formParamsCount) {
18✔
296
            throw new InvalidSwaggerSpecException(
1✔
297
                "Operation '{$operationId}' has body and formData parameters. Only one or the other is allowed."
1✔
298
            );
1✔
299
        }
300
    }
301

302
    protected function validateParameterType(array $param, array $operation, string $paramId, string $operationId): void
303
    {
304
        switch ($param['in']) {
23✔
305
            case 'body':
23✔
306
                $requiredFields = ['schema'];
17✔
307
                $validTypes = self::SCHEMA_TYPES;
17✔
308

309
                break;
17✔
310
            case 'formData':
15✔
311
                $this->validateFormDataConsumes($operation, $operationId);
4✔
312

313
                $requiredFields = ['type'];
2✔
314
                $validTypes = array_merge(self::PRIMITIVE_TYPES, ['file']);
2✔
315

316
                break;
2✔
317
            default:
318
                $requiredFields = ['type'];
15✔
319
                $validTypes = self::PRIMITIVE_TYPES;
15✔
320
        }
321

322
        $this->validateFieldsPresent($requiredFields, $paramId);
23✔
323

324
        $schema = Arr::get($param, 'schema', $param);
23✔
325
        $this->validateType($schema, $validTypes, $paramId);
23✔
326
    }
327

328
    protected function validateHeader(array $header, string $headerId): void
329
    {
330
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['header'], $headerId);
4✔
331
        $this->validateType($header, self::PRIMITIVE_TYPES, $headerId);
3✔
332
        $this->validateFieldValue("{$headerId}.collectionFormat", self::ALLOWED_VALUES['header_collection_format']);
3✔
333

334
        if (!empty($header['items'])) {
3✔
335
            $this->validateItems($header['items'], $headerId);
2✔
336
        }
337
    }
338

339
    protected function validateItems(array $items, string $itemsId): void
340
    {
341
        $this->validateFieldsPresent(self::REQUIRED_FIELDS['item'], $itemsId);
3✔
342
        $this->validateType($items, self::PRIMITIVE_TYPES, $itemsId);
2✔
343
        $this->validateFieldValue("{$itemsId}.collectionFormat", self::ALLOWED_VALUES['items_collection_format']);
2✔
344
    }
345

346
    protected function getMissingFields(array $requiredFields, array $doc, ?string $fieldName = null): array
347
    {
348
        return array_diff($requiredFields, array_keys(Arr::get($doc, $fieldName)));
53✔
349
    }
350

351
    protected function validateFieldsPresent(array $requiredFields, ?string $fieldName = null): void
352
    {
353
        $missingDocFields = $this->getMissingFields($requiredFields, $this->doc, $fieldName);
53✔
354

355
        if (!empty($missingDocFields)) {
53✔
356
            throw new MissingFieldException($missingDocFields, $fieldName);
10✔
357
        }
358
    }
359

360
    protected function validateFieldValue(string $fieldName, array $allowedValues): void
361
    {
362
        $inputValue = Arr::wrap(Arr::get($this->doc, $fieldName, []));
50✔
363
        $approvedValues = array_intersect($inputValue, $allowedValues);
50✔
364
        $invalidValues = array_diff($inputValue, $approvedValues);
50✔
365

366
        if (!empty($invalidValues)) {
50✔
367
            throw new InvalidFieldValueException($fieldName, $allowedValues, $invalidValues);
1✔
368
        }
369
    }
370

371
    protected function validateRefs(): void
372
    {
373
        array_walk_recursive($this->doc, function ($item, $key) {
24✔
374
            if ($key === '$ref') {
24✔
375
                $refParts = explode('#/', $item);
5✔
376
                $refFilename = Arr::first($refParts);
5✔
377

378
                if (count($refParts) > 1) {
5✔
379
                    $path = pathinfo(Arr::last($refParts));
4✔
380
                    $refParentKey = $path['dirname'];
4✔
381
                    $refKey = $path['filename'];
4✔
382
                }
383

384
                if (!empty($refFilename) && !file_exists($refFilename)) {
5✔
385
                    throw new MissingRefFileException($refFilename);
1✔
386
                }
387

388
                $missingRefs = $this->getMissingFields(
4✔
389
                    (array) $refKey,
4✔
390
                    !empty($refFilename)
4✔
391
                        ? json_decode(file_get_contents($refFilename), true)
1✔
392
                        : $this->doc,
4✔
393
                    $refParentKey
4✔
394
                );
4✔
395

396
                if (!empty($missingRefs)) {
4✔
397
                    if (!empty($refFilename)) {
2✔
398
                        throw new MissingExternalRefException($refKey, $refFilename);
1✔
399
                    } else {
400
                        throw new MissingLocalRefException($refKey, $refParentKey);
1✔
401
                    }
402
                }
403
            }
404
        });
24✔
405
    }
406

407
    protected function validateParamsUnique(array $params, string $operationId): void
408
    {
409
        $collection = collect($params);
24✔
410
        $duplicates = $collection->duplicates(function ($item) {
24✔
411
            return $item['in'] . $item['name'];
19✔
412
        });
24✔
413

414
        if ($duplicates->count()) {
24✔
415
            $duplicateIndex = $duplicates->keys()->first();
2✔
416

417
            throw new DuplicateParamException($params[$duplicateIndex]['in'], $params[$duplicateIndex]['name'], $operationId);
2✔
418
        }
419
    }
420

421
    protected function validateTagsUnique(): void
422
    {
423
        $tags = Arr::get($this->doc, 'tags', []);
25✔
424
        $tagNames = Arr::pluck($tags, 'name');
25✔
425
        $duplicates = $this->getArrayDuplicates($tagNames);
25✔
426

427
        if (!empty($duplicates)) {
25✔
428
            throw new DuplicateFieldException('tags.*.name', $duplicates);
1✔
429
        }
430
    }
431

432
    protected function validateOperationIdsUnique(): void
433
    {
434
        $operationIds = Arr::pluck(Arr::get($this->doc, 'paths', []), '*.operationId');
28✔
435
        $operationIds = Arr::flatten($operationIds);
28✔
436
        $duplicates = $this->getArrayDuplicates($operationIds);
28✔
437

438
        if (!empty($duplicates)) {
28✔
439
            throw new DuplicateFieldException('paths.*.*.operationId', $duplicates);
1✔
440
        }
441
    }
442

443
    protected function validateFormDataConsumes(array $operation, string $operationId): void
444
    {
445
        $consumes = Arr::get($operation, 'consumes', []);
4✔
446

447
        $requiredConsume = Arr::first($consumes, function ($consume) {
4✔
448
            return in_array($consume, [
3✔
449
                self::MIME_TYPE_APPLICATION_URLENCODED,
3✔
450
                self::MIME_TYPE_MULTIPART_FORM_DATA,
3✔
451
            ]);
3✔
452
        });
4✔
453

454
        if (empty($requiredConsume)) {
4✔
455
            throw new InvalidSwaggerSpecException(
2✔
456
                "Operation '{$operationId}' has body and formData parameters. Only one or the other is allowed."
2✔
457
            );
2✔
458
        }
459
    }
460

461
    protected function getArrayDuplicates(array $array): array
462
    {
463
        $array = array_filter($array);
40✔
464
        $duplicates = array_filter(array_count_values($array), function ($value) {
40✔
465
            return $value > 1;
9✔
466
        });
40✔
467

468
        return array_keys($duplicates);
40✔
469
    }
470

471
    protected function isValidStatusCode(string $code): bool
472
    {
473
        $code = intval($code);
12✔
474

475
        return $code >= 100 && $code < 600;
12✔
476
    }
477
}
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