├── src ├── Describer │ ├── WithFaker.php │ ├── WithRecursiveDescriber.php │ ├── CollectsClassReferences.php │ ├── WithTypeParser.php │ └── WithExampleGenerator.php ├── Parser │ ├── WithVariableDescriber.php │ ├── WithRouteReflections.php │ ├── CleanupsDescribedData.php │ ├── RoutesParserHelpers.php │ ├── WithReflections.php │ ├── RoutesParserEvents.php │ ├── WithDocParser.php │ └── WithAnnotationReader.php ├── SwaggerGeneratorServiceProvider.php ├── Parsers │ └── ClassParser.php ├── VariableDescriberService.php ├── Commands │ └── GenerateCommand.php └── Yaml │ └── Variable.php ├── oa ├── Ignore.php ├── RequestParamArray.php ├── ResponseParam.php ├── Property.php ├── PropertyRead.php ├── Parameters.php ├── PropertyIgnore.php ├── RequestBody.php ├── RequestParamIgnore.php ├── RequestParam.php ├── Tag.php ├── DescriptionExtender.php ├── Symlink.php ├── Secured.php ├── ResponseError.php ├── RequestBodyJson.php ├── ResponseClass.php ├── Parameter.php ├── BaseAnnotation.php ├── Response.php └── BaseValueDescribed.php ├── .ide-toolbox.metadata.json ├── composer.json ├── LICENSE.md ├── .github └── workflows │ └── make-a-release.yml ├── config └── swagger-generator.php └── README.MD /src/Describer/WithFaker.php: -------------------------------------------------------------------------------- 1 | faker)) { 22 | $this->faker = \Faker\Factory::create(); 23 | } 24 | 25 | return $this->faker; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /oa/Ignore.php: -------------------------------------------------------------------------------- 1 | describer)) { 22 | $this->describer = app('swagger.describer'); 23 | } 24 | 25 | return $this->describer; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /oa/RequestParamArray.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'parameters'); 24 | } 25 | 26 | /** 27 | * Get object string representation 28 | * @return string 29 | */ 30 | public function __toString() 31 | { 32 | return 'Parameters ' . count($this->parameters); 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public function toArray(): array 39 | { 40 | $data = []; 41 | foreach ($this->parameters as $parameter) { 42 | $data[] = $parameter->toArray(); 43 | } 44 | return $data; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Parser/WithRouteReflections.php: -------------------------------------------------------------------------------- 1 | getActionMethod() === 'Closure') { 23 | $closure = $route->getAction('uses'); 24 | 25 | return $this->reflectionClosure($closure); 26 | } 27 | $controller = $route->getController(); 28 | $method = $route->getActionMethod(); 29 | // Invokable controller 30 | if ($method === get_class($controller)) { 31 | return $this->reflectionMethod($controller, '__invoke'); 32 | } 33 | 34 | return $this->reflectionMethod($controller, $method); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /oa/PropertyIgnore.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'name'); 27 | } 28 | 29 | /** 30 | * Get object string representation 31 | * 32 | * @return string 33 | */ 34 | public function __toString() 35 | { 36 | if (! isset($this->name)) { 37 | throw new \RuntimeException(sprintf("You must set a \$name for '%s' annotation", __CLASS__)); 38 | } 39 | 40 | return $this->name; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digit-soft/laravel-swagger-generator", 3 | "description": "Swagger yaml generator", 4 | "keywords": ["api", "swagger", "open auth"], 5 | "license": "MIT", 6 | "version": "1.5.4", 7 | "authors": [ 8 | { 9 | "name": "Digit", 10 | "email": "digit.vova@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.0", 15 | "laravel/framework": "^7.30.6|^8.75|^9.1.9|^10.0|^11.0|^12.0", 16 | "symfony/yaml": "^6.1", 17 | "phpdocumentor/reflection-docblock": "^4.3|^5.0", 18 | "doctrine/annotations": "^1.6" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "DigitSoft\\Swagger\\": "src/", 23 | "OA\\": "oa/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "DigitSoft\\Swagger\\Tests\\": "tests/" 29 | } 30 | }, 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "DigitSoft\\Swagger\\SwaggerGeneratorServiceProvider" 35 | ] 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 digit-soft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /oa/RequestBody.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'content'); 34 | } 35 | 36 | /** 37 | * Get object string representation 38 | * 39 | * @return string 40 | */ 41 | public function __toString() 42 | { 43 | return (string)$this->description; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /oa/RequestParamIgnore.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'name'); 29 | } 30 | 31 | /** 32 | * Get object string representation 33 | * 34 | * @return string 35 | */ 36 | public function __toString() 37 | { 38 | if (! isset($this->name)) { 39 | throw new \RuntimeException(sprintf("You must set a \$name for '%s' annotation", __CLASS__)); 40 | } 41 | 42 | return $this->name; 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /oa/RequestParam.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'name'); 31 | } 32 | 33 | /** 34 | * Get object string representation 35 | * @return string 36 | */ 37 | public function __toString() 38 | { 39 | if (! isset($this->name)) { 40 | throw new \RuntimeException("'OA\Tag::\$name' is required"); 41 | } 42 | 43 | return preg_replace('/[\s_]+/u', '-', $this->name); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /oa/DescriptionExtender.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'value'); 22 | } 23 | 24 | /** 25 | * Set controller action name (method) before dumping annotation. 26 | * 27 | * @param string $action 28 | * @return $this 29 | */ 30 | public function setAction(string $action): static 31 | { 32 | $this->action = $action; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * Get object string representation. 39 | * 40 | * @return string 41 | */ 42 | public function __toString() 43 | { 44 | /** @noinspection MagicMethodsValidityInspection */ 45 | return is_string($this->value) ? $this->value : ''; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/make-a-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | - name: Get commit summary 18 | id: get_commit_summary 19 | run: | 20 | previousTag=$(git tag --sort=-creatordate | sed -n 2p) 21 | echo "previousTag : $previousTag" 22 | 23 | commitSummary="$(git log --no-merges --pretty=format:"%s [%an]" $previousTag..${{ github.ref }})" 24 | echo 'COMMIT_SUMMARY<> $GITHUB_ENV 25 | echo "$commitSummary" >> $GITHUB_ENV 26 | echo 'EOF' >> $GITHUB_ENV 27 | - name: Create Release 28 | id: create_release 29 | uses: actions/create-release@v1 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | with: 33 | tag_name: ${{ github.ref }} 34 | release_name: Release ${{ github.ref }} 35 | body: ${{env.COMMIT_SUMMARY}} 36 | draft: false 37 | prerelease: false 38 | -------------------------------------------------------------------------------- /oa/Symlink.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'class'); 38 | } 39 | 40 | /** 41 | * Get object string representation 42 | * @return string 43 | */ 44 | public function __toString() 45 | { 46 | if (! isset($this->class)) { 47 | throw new \RuntimeException("'OA\Symlink::\$class' is required"); 48 | } 49 | 50 | return $this->class; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Parser/CleanupsDescribedData.php: -------------------------------------------------------------------------------- 1 | &$value) { 21 | if (is_array($value)) { 22 | static::handleIncompatibleTypeKeys($value); 23 | } 24 | } 25 | unset($value); 26 | if (($type = $target['type'] ?? null) === null) { 27 | return; 28 | } 29 | // Make enums unique 30 | if (is_array($enum = $target['enum'] ?? null)) { 31 | $target['enum'] = array_values(array_unique($enum)); 32 | } 33 | switch ($type) { 34 | case Variable::SW_TYPE_OBJECT: 35 | Arr::forget($target, ['items']); 36 | // if (!isset($target['properties']) && !isset($target['example'])) { 37 | // $target['properties'] = []; 38 | // } 39 | break; 40 | case Variable::SW_TYPE_ARRAY: 41 | Arr::forget($target, ['properties']); 42 | if (isset($target['example'], $target['items']['example'])) { 43 | Arr::forget($target, ['items.example']); 44 | } 45 | break; 46 | default: 47 | Arr::forget($target, ['items', 'properties']); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /oa/Secured.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'scheme'); 33 | } 34 | 35 | /** 36 | * Get object string representation. 37 | * 38 | * @return string 39 | */ 40 | public function __toString() 41 | { 42 | return $this->scheme; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function toArray(): array 49 | { 50 | return $this->getSecurityScheme(); 51 | } 52 | 53 | /** 54 | * Get security scheme data. 55 | * 56 | * @return array 57 | * @throws \Exception 58 | */ 59 | protected function getSecurityScheme() 60 | { 61 | $schemes = config('swagger-generator.content.components.securitySchemes', []); 62 | if (empty($schemes)) { 63 | throw new \Exception("There are no security schemes defined"); 64 | } 65 | $key = $this->scheme ?? key($schemes); 66 | if (!isset($schemes[$key])) { 67 | throw new \Exception("Security scheme '{$key}' not defined"); 68 | } 69 | return [$key => $this->content]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SwaggerGeneratorServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([$configPath => $publishPath], 'config'); 24 | } 25 | 26 | /** 27 | * Register the service provider. 28 | */ 29 | public function register() 30 | { 31 | $configPath = __DIR__ . '/../config/swagger-generator.php'; 32 | $this->mergeConfigFrom($configPath, 'swagger-generator'); 33 | $this->registerCommands(); 34 | $this->registerComponents(); 35 | } 36 | 37 | /** 38 | * Register package components 39 | */ 40 | protected function registerComponents() 41 | { 42 | $this->app->singleton('swagger.describer', function ($app) { 43 | return new VariableDescriberService($app['files']); 44 | }); 45 | $this->app->alias('swagger-describer', VariableDescriberService::class); 46 | } 47 | 48 | /** 49 | * Register console commands 50 | */ 51 | protected function registerCommands() 52 | { 53 | $this->app->singleton('command.swagger.generate', function ($app) { 54 | return new GenerateCommand($app['router'], $app['files']); 55 | }); 56 | 57 | $this->commands([ 58 | 'command.swagger.generate', 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /oa/ResponseError.php: -------------------------------------------------------------------------------- 1 | _setProperties = $this->configureSelf($values, 'status'); 29 | if (! $this->wasSetInConstructor('content')) { 30 | $this->content = $this->getDefaultContentByStatus(); 31 | $this->usedDefaultContent = true; 32 | } 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public function getComponentKey(): ?string 39 | { 40 | $isDefault = $this->usedDefaultContent && $this->description === $this->defaultDescription; 41 | if (!$isDefault) { 42 | return null; 43 | } 44 | return 'ResponseError_' . $this->status; 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function hasData(): bool 51 | { 52 | return true; 53 | } 54 | 55 | /** 56 | * Get default content for given error code 57 | * 58 | * @return mixed|null 59 | */ 60 | protected function getDefaultContentByStatus(): mixed 61 | { 62 | $content = static::defaultContentList(); 63 | 64 | return $content[$this->status] ?? null; 65 | } 66 | 67 | /** 68 | * Get default content list by status 69 | * @return array 70 | */ 71 | protected static function defaultContentList() 72 | { 73 | return [ 74 | 422 => ['request_attribute' => ['Error message #1', 'Error message #2']], 75 | ]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Parser/RoutesParserHelpers.php: -------------------------------------------------------------------------------- 1 | components[$type][$key] ?? null; 49 | } 50 | 51 | /** 52 | * Set component. 53 | * 54 | * @param array $component 55 | * @param string $key 56 | * @param string $type 57 | */ 58 | protected function setComponent(array $component, string $key, string $type = self::COMPONENT_RESPONSE): void 59 | { 60 | if (empty($key)) { 61 | return; 62 | } 63 | $this->components[$type][$key] = $component; 64 | } 65 | 66 | /** 67 | * Get component reference name. 68 | * 69 | * @param string $key 70 | * @param string $type 71 | * @return string 72 | */ 73 | protected function getComponentReference(string $key, string $type = self::COMPONENT_RESPONSE): string 74 | { 75 | $keys = ['components', $type, $key]; 76 | 77 | return '#/' . implode('/', $keys); 78 | } 79 | 80 | /** 81 | * Get array element by string key (camel|snake). 82 | * 83 | * @param array $array 84 | * @param string $key 85 | * @return mixed|null 86 | */ 87 | protected static function getArrayElemByStrKey(array $array, string $key): mixed 88 | { 89 | if (isset($array[$key])) { 90 | return $array[$key]; 91 | } 92 | $keyCamel = Str::camel($key); 93 | 94 | return $array[$keyCamel] ?? null; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /oa/RequestBodyJson.php: -------------------------------------------------------------------------------- 1 | content); 33 | } 34 | 35 | /** 36 | * Process content row by row recursively. 37 | * 38 | * @param array $content 39 | * @return array 40 | */ 41 | protected function processContent(array $content): array 42 | { 43 | $result = []; 44 | $required = []; 45 | foreach ($content as $key => $row) { 46 | if (is_object($row)) { 47 | if (! method_exists($row, 'toArray')) { 48 | continue; 49 | } 50 | if ($row instanceof RequestParam) { 51 | $row->toArrayRecursive($result); 52 | if ($row->required && ! $row->isNested()) { 53 | $required[] = $row->name; 54 | } 55 | } else { 56 | $result[$key] = $this->describer()->describe($row->toArray()); 57 | } 58 | } elseif (is_array($row)) { 59 | $result[$key] = $this->processContent($row); 60 | } else { 61 | $result[$key] = $row; 62 | } 63 | if (isset($result[$key]) && is_array($result[$key])) { 64 | $currentRow = &$result[$key]; 65 | static::handleIncompatibleTypeKeys($currentRow); 66 | } 67 | } 68 | if (! empty($result) && ! empty($requiredAttributes = array_unique(array_merge($this->required, $required)))) { 69 | $result['required'] = $requiredAttributes; 70 | } 71 | 72 | return $result; 73 | } 74 | 75 | /** 76 | * @inheritdoc 77 | */ 78 | public function toArray(): array 79 | { 80 | if (! is_array($this->content)) { 81 | throw new \RuntimeException("'OA\RequestBodyJson::\$content' must be array"); 82 | } 83 | $content = $this->processContent($this->content); 84 | 85 | return [ 86 | 'description' => $this->description ?? '', 87 | // 'required' => true, 88 | 'content' => [ 89 | $this->contentType => [ 90 | 'schema' => $content, 91 | ], 92 | ], 93 | ]; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Parser/WithReflections.php: -------------------------------------------------------------------------------- 1 | reflectionClass($class); 25 | $methodName = $refClass->name . '::' . $method; 26 | if (! isset($this->reflections[$methodName])) { 27 | $this->reflections[$methodName] = $refClass->getMethod($method); 28 | } 29 | 30 | return $this->reflections[$methodName]; 31 | } 32 | 33 | /** 34 | * Get method reflection 35 | * 36 | * @param object|string $class 37 | * @param string $property 38 | * @return \ReflectionProperty 39 | */ 40 | protected function reflectionProperty(string|object $class, string $property): \ReflectionProperty 41 | { 42 | $refClass = $this->reflectionClass($class); 43 | $propertyName = $refClass->name . '->' . $property; 44 | if (! isset($this->reflections[$propertyName])) { 45 | $this->reflections[$propertyName] = $refClass->getProperty($property); 46 | } 47 | 48 | return $this->reflections[$propertyName]; 49 | } 50 | 51 | /** 52 | * Get class reflection 53 | * 54 | * @param string|object $class 55 | * @return \ReflectionClass 56 | */ 57 | protected function reflectionClass(string|object $class): \ReflectionClass 58 | { 59 | $className = is_object($class) ? get_class($class) : $class; 60 | if (! isset($this->reflections[$className])) { 61 | $this->reflections[$className] = new \ReflectionClass($className); 62 | } 63 | 64 | return $this->reflections[$className]; 65 | } 66 | 67 | /** 68 | * Get closure reflection 69 | * 70 | * @param \Closure $closure 71 | * @return \ReflectionFunction 72 | */ 73 | protected function reflectionClosure($closure): \ReflectionFunction 74 | { 75 | return new \ReflectionFunction($closure); 76 | } 77 | 78 | /** 79 | * Get method doc block 80 | * 81 | * @param string|object $class 82 | * @param string $method 83 | * @return string|null 84 | */ 85 | protected function docBlockMethod(string|object $class, string $method): ?string 86 | { 87 | $ref = $this->reflectionMethod($class, $method); 88 | $docBlock = $ref->getDocComment(); 89 | 90 | return is_string($docBlock) ? $docBlock : null; 91 | } 92 | 93 | /** 94 | * Get class doc block 95 | * 96 | * @param string|object $class 97 | * @return string|null 98 | */ 99 | protected function docBlockClass(string|object $class): ?string 100 | { 101 | $ref = $this->reflectionClass($class); 102 | $docBlock = $ref->getDocComment(); 103 | 104 | return is_string($docBlock) ? $docBlock : null; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /config/swagger-generator.php: -------------------------------------------------------------------------------- 1 | [ 10 | 'only' => [], 11 | 'not' => [], 12 | 'matches' => [], 13 | 'notMatches' => [], 14 | 'methods' => ['GET', 'POST', 'PUT', 'DELETE'], 15 | ], 16 | /* 17 | |--------------------------------------------------------- 18 | | Settings for output files 19 | |--------------------------------------------------------- 20 | | For absolute URL start it with slash "/", otherwise relative to app base path 21 | */ 22 | 'output' => [ 23 | 'path' => 'public/swagger', 24 | 'file_name' => 'main.yml', 25 | ], 26 | /* 27 | |--------------------------------------------------------- 28 | | Content array to merge with 29 | |--------------------------------------------------------- 30 | */ 31 | 'content' => [ 32 | 'openapi' => '3.0.0', 33 | 'info' => [ 34 | 'description' => 'Service documentation', 35 | 'version' => '1.0.0', 36 | 'title' => 'Laravel API', 37 | ], 38 | 'servers' => [ 39 | [ 40 | 'url' => 'https://localhost', 41 | 'description' => 'Local server', 42 | ], 43 | ], 44 | 'components' => [ 45 | 'securitySchemes' => [ 46 | 'bearerAuth' => [ 47 | 'type' => 'http', 48 | 'scheme' => 'bearer', 49 | 'bearerFormat' => 'JWT', 50 | ], 51 | ], 52 | ], 53 | ], 54 | /* 55 | |--------------------------------------------------------- 56 | | YML file paths to merge with (before parsing) 57 | |--------------------------------------------------------- 58 | */ 59 | 'contentFilesBefore' => [], 60 | /* 61 | |--------------------------------------------------------- 62 | | YML file paths to merge with (after parsing) 63 | |--------------------------------------------------------- 64 | */ 65 | 'contentFilesAfter' => [], 66 | /* 67 | |--------------------------------------------------------- 68 | | Strip base url prefix for output, NULL to disable 69 | |--------------------------------------------------------- 70 | */ 71 | 'stripBaseUrl' => null, 72 | /* 73 | |--------------------------------------------------------- 74 | | List of ignored annotation names 75 | |--------------------------------------------------------- 76 | */ 77 | 'ignoredAnnotationNames' => ['mixin'], 78 | /* 79 | |--------------------------------------------------------- 80 | | List of classes to additionally generate definitions 81 | |--------------------------------------------------------- 82 | | 83 | | Item can be string, like 'App\Models\Product' 84 | | Or an array: [ 85 | | \App\Models\Product::class, // Class name 86 | | 'my_product', // Key for array and title, if NULL class name will be used 87 | | 'Class description', // Text description of a class, if NULL PHPDoc summary will be used 88 | | ['currency'] // Additional properties 89 | | ] 90 | | 91 | */ 92 | 'generateDefinitions' => [], 93 | ]; 94 | -------------------------------------------------------------------------------- /oa/ResponseClass.php: -------------------------------------------------------------------------------- 1 | content); 38 | $key = end($className); 39 | if (! empty($this->with)) { 40 | $key .= '__w_' . str_replace(['\\', '.'], '_', implode('_', $this->with)); 41 | } 42 | if (! empty($this->except)) { 43 | $key .= '__wo_' . str_replace(['\\', '.'], '_', implode('_', $this->except)); 44 | } 45 | $key .= $this->asList || $this->asPagedList ? '__list' : ''; 46 | $key .= $this->asPagedList ? '_paged' : ''; 47 | $key .= $this->asCursorPagedList ? '_paged_cursor' : ''; 48 | 49 | return $key; 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | protected function getContent(): ?array 56 | { 57 | $properties = $this->getModelProperties($this->content); 58 | $this->_hasNoData = empty($properties) || empty($properties['properties']) ? true : $this->_hasNoData; 59 | 60 | return $properties; 61 | } 62 | 63 | /** 64 | * Get model properties 65 | * 66 | * @param string $className 67 | * @param array $except 68 | * @return array 69 | */ 70 | protected function getModelProperties(string $className, array $except = []): array 71 | { 72 | if (! class_exists($className) && ! interface_exists($className)) { 73 | throw new \RuntimeException("Class or interface '{$className}' not found"); 74 | } 75 | 76 | $variable = Variable::fromDescription([ 77 | 'type' => $className, 78 | 'with' => $this->with, 79 | 'except' => array_unique(array_merge($this->except, $except)), 80 | ]); 81 | 82 | return $variable->describe(); 83 | } 84 | 85 | /** 86 | * Get ignored properties. 87 | * 88 | * @param string[] $classNames 89 | * @return string[] 90 | */ 91 | protected function getModelsIgnoredProperties(array $classNames): array 92 | { 93 | $ignored = []; 94 | foreach ($classNames as $className) { 95 | /** @var \OA\PropertyIgnore[] $annotations */ 96 | $annotations = $this->classAnnotations($className, \OA\PropertyIgnore::class); 97 | $ignored[] = Arr::pluck($annotations, 'name', 'name'); 98 | } 99 | 100 | return ! empty($ignored) ? array_keys(array_merge([], ...$ignored)) : []; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /oa/Parameter.php: -------------------------------------------------------------------------------- 1 | in; 56 | // Path parameters are always required 57 | $data['required'] = $this->in === 'path' ? true : $this->required; 58 | $type = $data['schema']['type'] ?? 'string'; 59 | if (($style = $this->getStyle($this->in, $type)) !== null) { 60 | $data['style'] = $style; 61 | } 62 | if ($this->explode !== null) { 63 | $data['explode'] = $this->explode; 64 | } 65 | 66 | return $data; 67 | } 68 | 69 | /** 70 | * @inheritdoc 71 | */ 72 | protected function getDumpedKeys(): array 73 | { 74 | return [ 75 | 'name', 'type', 'format', 'description', 76 | 'required', 'enum', 'example', 77 | ]; 78 | } 79 | 80 | /** 81 | * @inheritdoc 82 | */ 83 | protected function isSchemaTypeUsed(): bool 84 | { 85 | return true; 86 | } 87 | 88 | /** 89 | * Get parameter style. 90 | * 91 | * @param string $in 92 | * @param string|null $type 93 | * @return string|null 94 | */ 95 | protected function getStyle(string $in, ?string $type): ?string 96 | { 97 | if ($this->style !== null) { 98 | return $this->style; 99 | } 100 | $default = static::getDefaultStyles(); 101 | $key = $in . '.' . $type; 102 | 103 | return $default[$key] ?? $default['*'] ?? null; 104 | } 105 | 106 | /** 107 | * Get default styles keyed by `in`, `type` params 108 | * 109 | * @return array 110 | */ 111 | protected static function getDefaultStyles(): array 112 | { 113 | return [ 114 | // 'in.type' => 'style', 115 | // 'in.*' => 'style', 116 | 'query.array' => 'form', 117 | ]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /oa/BaseAnnotation.php: -------------------------------------------------------------------------------- 1 | configureSelf($values); 24 | } 25 | 26 | /** 27 | * Dumps object data as array 28 | * 29 | * @return array 30 | */ 31 | public function toArray(): array 32 | { 33 | $reflection = new \ReflectionClass($this); 34 | $data = []; 35 | foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { 36 | if ($property->isStatic() || ! $property->isInitialized($this)) { 37 | continue; 38 | } 39 | $value = $this->{$property->name}; 40 | $data[$property->name] = $value !== static::NULL_VALUE ? $value : null; 41 | } 42 | 43 | return $data; 44 | } 45 | 46 | /** 47 | * Load data into object 48 | * 49 | * @param array $data 50 | * @return static 51 | */ 52 | public function fill(array $data): static 53 | { 54 | $this->configureSelf($data); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Configure object 61 | * 62 | * @param array $config 63 | * @param string|null $defaultParam 64 | * @return array 65 | */ 66 | protected function configureSelf(array $config, ?string $defaultParam = null): array 67 | { 68 | $setParams = []; 69 | if (array_key_exists('value', $config) && ! property_exists($this, 'value') && $defaultParam !== null) { 70 | $this->{$defaultParam} = Arr::pull($config, 'value'); 71 | $setParams[] = $defaultParam; 72 | } 73 | foreach ($config as $key => $value) { 74 | if (property_exists($this, $key)) { 75 | $this->{$key} = $value; 76 | $setParams[] = $key; 77 | } 78 | } 79 | 80 | return $setParams; 81 | } 82 | 83 | /** 84 | * Magic getter 85 | * 86 | * @param string $name 87 | * @return mixed 88 | * @throws \ErrorException 89 | */ 90 | public function __get(string $name) 91 | { 92 | $getter = 'get' . Str::studly($name); 93 | if (! method_exists($this, $getter)) { 94 | throw new \ErrorException("Undefined property: " . __CLASS__ . "::\${$name}"); 95 | } 96 | 97 | return $this->{$getter}(); 98 | } 99 | 100 | /** 101 | * Magic setter. 102 | * 103 | * @param string $name 104 | * @param mixed $value 105 | * @throws \ErrorException 106 | */ 107 | public function __set(string $name, mixed $value): void 108 | { 109 | $setter = 'set' . Str::studly($name); 110 | if (! method_exists($this, $setter)) { 111 | throw new \ErrorException("Undefined property: " . __CLASS__ . "::\${$name}"); 112 | } 113 | $this->{$setter}($value); 114 | } 115 | 116 | /** 117 | * Magic `isset`. 118 | * 119 | * @param string $name 120 | * @return bool 121 | */ 122 | public function __isset(string $name): bool 123 | { 124 | $getter = 'get' . Str::studly($name); 125 | 126 | return method_exists($this, $getter) && $this->{$getter}() !== null; 127 | } 128 | 129 | /** 130 | * Get object string representation 131 | * 132 | * @return string 133 | */ 134 | abstract public function __toString(); 135 | } 136 | -------------------------------------------------------------------------------- /src/Describer/WithRecursiveDescriber.php: -------------------------------------------------------------------------------- 1 | swaggerType(strtolower(gettype($value))); 28 | $swaggerType = $swaggerType === 'null' ? null : $swaggerType; 29 | $desc = ['type' => $swaggerType]; 30 | $couldHaveExamples = [ 31 | Variable::SW_TYPE_STRING, 32 | Variable::SW_TYPE_INTEGER, 33 | Variable::SW_TYPE_NUMBER, 34 | Variable::SW_TYPE_BOOLEAN, 35 | ]; 36 | switch ($swaggerType) { 37 | case Variable::SW_TYPE_OBJECT: 38 | $desc = $this->describeObject($value, $additionalData); 39 | break; 40 | case Variable::SW_TYPE_ARRAY: 41 | $desc = $this->describeArray($value, $additionalData, $withExample); 42 | break; 43 | } 44 | if ($withExample && in_array($swaggerType, $couldHaveExamples, true)) { 45 | $desc['example'] = $value; 46 | if (! empty($additionalData)) { 47 | $desc = array_merge($desc, $additionalData); 48 | } 49 | } 50 | 51 | return $desc; 52 | } 53 | 54 | /** 55 | * Describe object 56 | * 57 | * @param object $value 58 | * @param array $additionalData 59 | * @param bool $withExample 60 | * @return array 61 | */ 62 | protected function describeObject(object $value, array $additionalData = [], bool $withExample = true): array 63 | { 64 | $data = [ 65 | 'type' => 'object', 66 | 'properties' => [], 67 | ]; 68 | if (method_exists($value, 'toArray')) { 69 | $objProperties = app()->call([$value, 'toArray'], ['request' => request()]); 70 | } else { 71 | $reflection = $this->reflectionClass($value); 72 | $refProperties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); 73 | $objProperties = []; 74 | foreach ($refProperties as $refProperty) { 75 | if ($refProperty->isStatic()) { 76 | continue; 77 | } 78 | $name = $refProperty->getName(); 79 | $objProperties[$name] = $value->{$name}; 80 | } 81 | } 82 | foreach ($objProperties as $key => $val) { 83 | $data['properties'][$key] = $this->describeValue($val, $additionalData[$key] ?? [], $withExample); 84 | } 85 | 86 | return $data; 87 | } 88 | 89 | /** 90 | * Describe array 91 | * 92 | * @param array $value 93 | * @param array $additionalData 94 | * @param bool $withExample 95 | * @return array 96 | */ 97 | protected function describeArray(array $value, array $additionalData = [], bool $withExample = true): array 98 | { 99 | if (empty($value)) { 100 | $data = [ 101 | 'type' => 'object', 102 | ]; 103 | } elseif (Arr::isAssoc($value)) { 104 | $data = [ 105 | 'type' => 'object', 106 | 'properties' => [], 107 | ]; 108 | foreach ($value as $key => $val) { 109 | $data['properties'][$key] = $this->describeValue($val, $additionalData[$key] ?? [], $withExample); 110 | } 111 | } else { 112 | $additionalDataRow = $additionalData[0] ?? []; 113 | $data = [ 114 | 'type' => 'array', 115 | 'items' => $this->describeValue(reset($value), $additionalDataRow), 116 | ]; 117 | } 118 | 119 | return $data; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Parser/RoutesParserEvents.php: -------------------------------------------------------------------------------- 1 | output instanceof OutputStyle) { 46 | return; 47 | } 48 | $this->output->progressStart(count($this->routes)); 49 | } 50 | 51 | /** 52 | * Finish parsing 53 | */ 54 | protected function eventParseFinish(): void 55 | { 56 | if (! $this->output instanceof OutputStyle) { 57 | return; 58 | } 59 | $this->output->progressFinish(); 60 | if (! empty($this->skippedRoutes)) { 61 | $this->output->warning(strtr('There are {count} skipped routes', ['{count}' => count($this->skippedRoutes)])); 62 | if ($this->output->isVerbose()) { 63 | $routePaths = []; 64 | foreach ($this->skippedRoutes as $route) { 65 | $routePaths[] = [ 66 | $route->uri(), 67 | json_encode($route->methods()), 68 | ]; 69 | } 70 | $this->output->table(['URI', 'Methods'], $routePaths); 71 | } 72 | } 73 | if (! empty($this->failedFormRequests)) { 74 | $failedRequests = []; 75 | foreach ($this->failedFormRequests as $key => $row) { 76 | /** @var \Throwable $exception */ 77 | [$className, $exception] = $row; 78 | $exceptionStr = $exception ? get_class($exception) . "\n" . $exception->getMessage() : '-'; 79 | $failedRequests[$className] = [$className, $exceptionStr]; 80 | } 81 | $this->output->warning(strtr('There are {count} form requests where failed to get rules', ['{count}' => count($failedRequests)])); 82 | if ($this->output->isVerbose()) { 83 | $this->output->table(['Class name', 'Exception'], $failedRequests); 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Route processed 90 | * 91 | * @param Route $route 92 | */ 93 | protected function eventRouteProcessed(Route $route): void 94 | { 95 | if (! $this->output instanceof OutputStyle) { 96 | return; 97 | } 98 | $this->output->progressAdvance(); 99 | } 100 | 101 | /** 102 | * Route skipped 103 | * 104 | * @param Route $route 105 | */ 106 | protected function eventRouteSkipped(Route $route): void 107 | { 108 | $this->skippedRoutes[] = $route; 109 | if (! $this->output instanceof OutputStyle) { 110 | return; 111 | } 112 | $this->output->progressAdvance(); 113 | } 114 | 115 | /** 116 | * Failed to fetch from request data 117 | * 118 | * @param \Illuminate\Foundation\Http\FormRequest $request 119 | * @param string|null $exception 120 | */ 121 | protected function eventRouteFormRequestFailed(object $request, ?object $exception = null): void 122 | { 123 | $this->failedFormRequests[] = [get_class($request), $exception]; 124 | } 125 | 126 | /** 127 | * Some problem found 128 | * 129 | * @param string $problemType 130 | * @param Route $route 131 | * @param string|null $additional 132 | */ 133 | protected function eventProblemFound(string $problemType, Route $route, ?string $additional = null): void 134 | { 135 | $this->problems[$problemType] = $this->problems[$problemType] ?? []; 136 | $this->problems[$problemType][$route->uri()] = [$route, $additional]; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Parser/WithDocParser.php: -------------------------------------------------------------------------------- 1 | getDocFactory()->create($docStr)->getSummary(); 32 | } 33 | 34 | /** 35 | * Get doc tags. 36 | * 37 | * @param string $docStr 38 | * @param string|null $tagName 39 | * @return \phpDocumentor\Reflection\DocBlock\Tag[] 40 | */ 41 | protected function getDocTags(string $docStr, ?string $tagName = null): array 42 | { 43 | if (trim($docStr) === '') { 44 | return []; 45 | } 46 | $docBlock = $this->getDocFactory()->create($docStr); 47 | 48 | return $tagName !== null ? $docBlock->getTagsByName($tagName) : $docBlock->getTags(); 49 | } 50 | 51 | /** 52 | * Get property or param tags as info array. 53 | * 54 | * @param string $docStr PHPDoc string 55 | * @param string $tagName Tag name (property, property-read, property-write, param) 56 | * @param array|null $only array with permitted variable names 57 | * @param array|null $not array with NOT permitted variable names 58 | * @return array Info about tags indexed by variable name 59 | */ 60 | protected function getDocTagsPropertiesDescribed(string $docStr, string $tagName = 'property', ?array $only = null, ?array $not = null): array 61 | { 62 | $tags = $this->getDocTagsProperties($docStr, $tagName, $only, $not); 63 | if (empty($tags)) { 64 | return []; 65 | } 66 | $result = []; 67 | foreach ($tags as $tag) { 68 | $type = (string)$tag->getType(); 69 | $name = $tag->getVariableName(); 70 | $description = $tag->getDescription(); 71 | [$typeNormalized, $nullable] = $this->describer()->normalizeTypeAndNullableFlag($type); 72 | $result[$name] = [ 73 | 'type' => $typeNormalized, 74 | 'description' => $description?->render(), 75 | ]; 76 | if ($nullable) { 77 | $result[$name]['nullable'] = $nullable; 78 | } 79 | } 80 | 81 | return $result; 82 | } 83 | 84 | /** 85 | * Get property or param tags. 86 | * 87 | * @param string $docStr PHPDoc string 88 | * @param string $tagName Tag name (property, property-read, property-write, param) 89 | * @param array|null $only array with permitted variable names 90 | * @param array|null $not array with NOT permitted variable names 91 | * @return Property[]|PropertyRead[]|PropertyWrite[] 92 | */ 93 | protected function getDocTagsProperties(string $docStr, string $tagName = 'property', ?array $only = null, ?array $not = null): array 94 | { 95 | /** @var Property[]|PropertyRead[]|PropertyWrite[]|Param[] $propertiesRaw */ 96 | $propertiesRaw = $this->getDocTags($docStr, $tagName); 97 | $properties = []; 98 | foreach ($propertiesRaw as $property) { 99 | $properties[$property->getVariableName()] = $property; 100 | } 101 | if ($only !== null) { 102 | $only = array_combine($only, $only); 103 | $properties = array_intersect_key($properties, $only); 104 | } 105 | if ($not !== null) { 106 | $not = array_combine($not, $not); 107 | $properties = array_diff_key($properties, $not); 108 | } 109 | 110 | return $properties; 111 | } 112 | 113 | /** 114 | * Get Doc factory. 115 | * 116 | * @return \phpDocumentor\Reflection\DocBlockFactory 117 | */ 118 | protected function getDocFactory(): DocBlockFactory 119 | { 120 | if (! isset($this->docFactory)) { 121 | $this->docFactory = DocBlockFactory::createInstance(); 122 | } 123 | 124 | return $this->docFactory; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Describer/CollectsClassReferences.php: -------------------------------------------------------------------------------- 1 | $component) { 51 | $keyDotted = str_replace('/', '.', mb_substr($key, 13)); // 13 = length of `#/components/` 52 | Arr::set($target, $keyDotted, $component); 53 | } 54 | 55 | return static::$collectedClassReferences; 56 | } 57 | 58 | /** 59 | * Get reference if it was collected for given class. 60 | * 61 | * @param string $className 62 | * @param array $with 63 | * @param array $except 64 | * @param array $only 65 | * @return array|null 66 | */ 67 | protected static function getCollectedClassReference(string $className, array $with = [], array $except = [], array $only = []): ?array 68 | { 69 | if (! static::$collectClassRefs) { 70 | return null; 71 | } 72 | $refPath = static::getCollectedClassReferenceName($className, $with, $except, $only); 73 | if (isset(static::$collectedClassReferences[$refPath])) { 74 | return ['$ref' => $refPath]; 75 | } 76 | 77 | return null; 78 | } 79 | 80 | /** 81 | * Save class data for reference. 82 | * 83 | * @param string $className 84 | * @param array $classDescribed 85 | * @param array $with 86 | * @param array $except 87 | * @param array $only 88 | * @param bool $returnRef 89 | * @return array 90 | */ 91 | protected static function setCollectedClassReference(string $className, array $classDescribed, array $with = [], array $except = [], array $only = [], bool $returnRef = true): array 92 | { 93 | if (! static::$collectClassRefs) { 94 | return $classDescribed; 95 | } 96 | $refPath = static::getCollectedClassReferenceName($className, $with, $except, $only); 97 | static::$collectedClassReferences[$refPath] = $classDescribed; 98 | 99 | return $returnRef ? ['$ref' => $refPath] : $classDescribed; 100 | } 101 | 102 | /** 103 | * Compose a key for reference. 104 | * 105 | * @param string $className 106 | * @param array $with 107 | * @param array $except 108 | * @param array $only 109 | * @return string 110 | */ 111 | private static function getCollectedClassReferenceName(string $className, array $with = [], array $except = [], array $only = []): string 112 | { 113 | $className = trim($className, " \t\n\r\0\x0B\\"); 114 | sort($with); 115 | sort($except); 116 | sort($only); 117 | $classNameWithAttributes = $className 118 | . (! empty($with) ? '__w_'. implode('_', $with) : '') 119 | . (! empty($except) ? '__wo_' . implode('_', $except) : '') 120 | . (! empty($only) ? '__o_' . implode('_', $only) : ''); 121 | if (isset(static::$collectedClassReferencesKeys[$classNameWithAttributes])) { 122 | return static::$collectedClassReferencesKeys[$classNameWithAttributes]; 123 | } 124 | $classNameSafe = str_replace(['\\', '.'], '_', $classNameWithAttributes); 125 | $keys = ['components', RoutesParser::COMPONENT_OBJECTS, $classNameSafe]; 126 | 127 | return static::$collectedClassReferencesKeys[$classNameWithAttributes] = '#/' . implode('/', $keys); 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/Parsers/ClassParser.php: -------------------------------------------------------------------------------- 1 | className = $className; 44 | } 45 | 46 | /** 47 | * Get model properties 48 | * 49 | * @param bool $onlyVisible 50 | * @param bool $describeClasses 51 | * @return array 52 | */ 53 | public function properties(bool $onlyVisible = true, bool $describeClasses = true): array 54 | { 55 | $appends = []; 56 | $hidden = null; 57 | if ($this->isModel()) { 58 | /** @var \Illuminate\Database\Eloquent\Model|\DigitSoft\StaticModels\Model $instance */ 59 | $instance = $this->instantiate(); 60 | $hidden = $onlyVisible ? $instance->getHidden() : null; 61 | $appends = $this->getModelAppends($instance); 62 | } 63 | $properties = $this->getPropertiesDescribed('property', null, $hidden, $describeClasses); 64 | 65 | return ! empty($appends) 66 | ? $this->describer()->merge($properties, $this->getPropertiesDescribed('property-read', $appends, null, $describeClasses)) 67 | : $properties; 68 | } 69 | 70 | /** 71 | * Get `property-read` tags. 72 | * 73 | * @param array|null $only 74 | * @param array|null $not 75 | * @param bool $describeClasses 76 | * @return \phpDocumentor\Reflection\DocBlock\Tag[] 77 | */ 78 | public function propertiesRead(?array $only = null, ?array $not = null, bool $describeClasses = true): array 79 | { 80 | return $this->getPropertiesDescribed('property-read', $only, $not, $describeClasses); 81 | } 82 | 83 | /** 84 | * Get summary from class PHPDoc. 85 | * 86 | * @return string|null 87 | */ 88 | public function docSummary(): ?string 89 | { 90 | $docStr = $this->docBlockClass($this->className); 91 | if ($docStr === null) { 92 | return null; 93 | } 94 | 95 | return $this->getDocSummary($docStr); 96 | } 97 | 98 | /** 99 | * Get described properties. 100 | * 101 | * @param string $tag 102 | * @param array|null $only 103 | * @param array|null $not 104 | * @param bool $describeClasses 105 | * @return \phpDocumentor\Reflection\DocBlock\Tag[] 106 | */ 107 | protected function getPropertiesDescribed(string $tag = 'property', ?array $only = null, ?array $not = null, bool $describeClasses = false): array 108 | { 109 | $docStr = $this->docBlockClass($this->className); 110 | if ($docStr === null) { 111 | return []; 112 | } 113 | 114 | $properties = $this->getDocTagsPropertiesDescribed($docStr, $tag, $only, $not); 115 | if (! $describeClasses) { 116 | return $properties; 117 | } 118 | 119 | // Describe only first level class objects 120 | foreach ($properties as $key => $row) { 121 | $row['type'] = $this->describer()->normalizeType($row['type']); 122 | if ($this->describer()->isTypeClassName($row['type'])) { 123 | $properties[$key] = $this->describer()->describe([]); 124 | $classDescription = (new static($this->describer()->normalizeType($row['type'], true)))->properties(); 125 | Arr::set($properties[$key], 'properties', $classDescription); 126 | if ($this->describer()->isTypeArray($row['type'])) { 127 | $propertyArray = $this->describer()->describe(['']); 128 | Arr::set($propertyArray, 'items', $properties[$key]); 129 | $properties[$key] = $propertyArray; 130 | } 131 | } 132 | } 133 | 134 | return $properties; 135 | } 136 | 137 | /** 138 | * Check that class is a subclass of model. 139 | * 140 | * @return bool 141 | */ 142 | protected function isModel(): bool 143 | { 144 | if (! isset($this->_isModel)) { 145 | $this->_isModel = ! empty(array_intersect($this->modelClasses, class_parents($this->className))); 146 | } 147 | 148 | return $this->_isModel; 149 | } 150 | 151 | /** 152 | * Get model appends attribute. 153 | * 154 | * @param \Illuminate\Database\Eloquent\Model $model 155 | * @return array 156 | */ 157 | protected function getModelAppends($model): array 158 | { 159 | $ref = $this->reflectionProperty($model, 'appends'); 160 | $ref->setAccessible(true); 161 | 162 | return $ref->getValue($model); 163 | } 164 | 165 | /** 166 | * Get instance of the model. 167 | * 168 | * @return object 169 | */ 170 | protected function instantiate(): object 171 | { 172 | return $this->instance ?? $this->instance = app()->make($this->className, $this->constructorParams); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/VariableDescriberService.php: -------------------------------------------------------------------------------- 1 | files = $files; 35 | } 36 | 37 | /** 38 | * Export YAML data to file 39 | * 40 | * @param array $content 41 | * @param string|null $filePath 42 | * @param bool $describe 43 | * @return string 44 | */ 45 | public function toYml(array $content = [], bool $describe = false, ?string $filePath = null): string 46 | { 47 | $arrayContent = $content; 48 | if ($describe) { 49 | $arrayContent = $this->describe($arrayContent); 50 | } 51 | $yamlContent = Yaml::dump($arrayContent, 20, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); 52 | if ($filePath !== null) { 53 | $this->files->put($filePath, $yamlContent); 54 | } 55 | 56 | return $yamlContent; 57 | } 58 | 59 | /** 60 | * Parse Yaml file 61 | * 62 | * @param string $filePath 63 | * @return array|null 64 | */ 65 | public function fromYml(string $filePath): ?array 66 | { 67 | $contentStr = $this->files->get($filePath); 68 | 69 | return is_array($parsed = Yaml::parse($contentStr)) ? $parsed : null; 70 | } 71 | 72 | /** 73 | * Describe variable 74 | * 75 | * @param mixed $variable 76 | * @param array $additionalData 77 | * @param bool $withExample 78 | * @return array 79 | */ 80 | public function describe(mixed $variable, array $additionalData = [], bool $withExample = true): array 81 | { 82 | return $this->describeValue($variable, $additionalData, $withExample); 83 | } 84 | 85 | /** 86 | * Shorten class name. 87 | * 88 | * @param string $className 89 | * @return string 90 | */ 91 | public function shortenClass(string $className): string 92 | { 93 | $className = ltrim($className, '\\'); 94 | if (isset($this->classShortcuts[$className])) { 95 | return $this->classShortcuts[$className]; 96 | } 97 | $classNameArray = explode('\\', $className); 98 | $classNameShort = $classNameShortBase = end($classNameArray); 99 | $num = 0; 100 | while (in_array($classNameShort, $this->classShortcuts, true)) { 101 | $classNameShort = $classNameShortBase . '_' . $num; 102 | $num++; 103 | } 104 | 105 | return $this->classShortcuts[$className] = $classNameShort; 106 | } 107 | 108 | /** 109 | * Merge arrays. 110 | * 111 | * @param array $a 112 | * @param array $b 113 | * @return array 114 | */ 115 | public function merge($a, $b) 116 | { 117 | $args = func_get_args(); 118 | $res = array_shift($args); 119 | while (!empty($args)) { 120 | foreach (array_shift($args) as $k => $v) { 121 | if (is_int($k)) { 122 | if (array_key_exists($k, $res)) { 123 | $res[] = $v; 124 | } else { 125 | $res[$k] = $v; 126 | } 127 | } elseif (is_array($v) && isset($res[$k]) && is_array($res[$k])) { 128 | $res[$k] = $this->merge($res[$k], $v); 129 | } else { 130 | $res[$k] = $v; 131 | } 132 | } 133 | } 134 | 135 | return $res; 136 | } 137 | 138 | /** 139 | * Merge arrays, maintain uniqueness in list arrays. 140 | * 141 | * @param array $a 142 | * @param array $b 143 | * @return array 144 | */ 145 | public function mergeUnique(array $a, array $b): array 146 | { 147 | $args = func_get_args(); 148 | $res = array_shift($args); 149 | while (! empty($args)) { 150 | foreach (array_shift($args) as $k => $v) { 151 | if (is_int($k)) { 152 | if (array_is_list($res)) { 153 | if (! in_array($v, $res, true)) { 154 | $res[] = $v; 155 | } 156 | } elseif (array_key_exists($k, $res)) { 157 | $res[] = $v; 158 | } else { 159 | $res[$k] = $v; 160 | } 161 | } elseif (is_array($v) && isset($res[$k]) && is_array($res[$k])) { 162 | $res[$k] = $this->mergeUnique($res[$k], $v); 163 | } else { 164 | $res[$k] = $v; 165 | } 166 | } 167 | } 168 | 169 | return $res; 170 | } 171 | 172 | /** 173 | * Merge arrays without merging keys under `properties` key. 174 | * 175 | * In such way we are trying to rewrite all under properties key, without recursive merge. 176 | * 177 | * @param array $a 178 | * @param array $b 179 | * @return array 180 | */ 181 | public function mergeWithPropertiesRewrite(array $a, array $b = []) 182 | { 183 | $args = func_get_args(); 184 | $prevKey = null; 185 | if (is_string(end($args))) { 186 | $prevKey = array_pop($args); 187 | } 188 | $res = array_shift($args); 189 | while (! empty($args)) { 190 | foreach (array_shift($args) as $k => $v) { 191 | if (is_int($k)) { 192 | if (array_key_exists($k, $res)) { 193 | $res[] = $v; 194 | } else { 195 | $res[$k] = $v; 196 | } 197 | } elseif ($prevKey !== 'properties' && is_array($v) && isset($res[$k]) && is_array($res[$k])) { 198 | $res[$k] = $this->mergeWithPropertiesRewrite($res[$k], $v, $k); 199 | } else { 200 | $res[$k] = $v; 201 | } 202 | } 203 | } 204 | 205 | return $res; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Swagger generator 2 | This package is made to automate API documentation for Swagger (Open Auth 3.0) 3 | 4 | ## Publish config 5 | ```bash 6 | php artisan vendor:publish --provider="DigitSoft\Swagger\SwaggerGeneratorServiceProvider" --tag="config" 7 | ``` 8 | 9 | ## Usage 10 | To generate doc file (YML) run following command: 11 | ```bash 12 | php artisan swagger:generate 13 | ``` 14 | To see problems with generation use `diagnose` mode, where file will not be generated, only helpful information will be printed. 15 | ```bash 16 | php artisan swagger:generate --diagnose 17 | ``` 18 | 19 | ## Describing your code 20 | ### Annotations list 21 | 22 | | Name | Description | Places to use | 23 | |-----------------------|---------------------------------------|---------------| 24 | | @OA\Response | Describes raw response | Controller method | 25 | | @OA\ResponseParam | Describes response parameter in `Response` | Inside `{}` of `Response` annotation | 26 | | @OA\ResponseClass | Describes response as class object | Controller method | 27 | | @OA\ResponseError | Describes error response (shortcut) | Controller method | 28 | | @OA\RequestBody | Describes request body | `FormRequest` class | 29 | | @OA\RequestBodyJson | Describes request body with `application\json` content type | `FormRequest` class | 30 | | @OA\RequestParam | Describes request body parameter | Used as argument in `@OA\RequestBody` annotation | 31 | | @OA\RequestParamArray | Describes request body parameter. Shortcut for array type parameter | Used as argument in `@OA\RequestBody` annotation | 32 | | @OA\Parameter | Describes route parameter | Controller method, Controller class | 33 | | @OA\Property | Describes class property | Class used for response | 34 | | @OA\PropertyIgnore | Mark class property as ignored | Class used for response | 35 | | @OA\Secured | Describes route as secured | Controller method | 36 | | @OA\Tag | Describes route tags | Controller method, Controller class | 37 | | @OA\Ignore | Marks whole controller or it's action as ignored | Controller method, Controller class | 38 | | @OA\Symlink | Describes symlink to another class | Class used for response | 39 | 40 | ### Responses 41 | Responses are parsed only if explicitly documented by `@Annotation`. It must be placed in PHPDoc of **controller method** that route use. 42 | RAW response: 43 | ```php 44 | /** 45 | * Controller method PHPDoc 46 | * 47 | * @OA\Response(true,contentType="application/json",description="Boolean response") 48 | * 49 | * @param Request $request 50 | * @return \Illuminate\Http\JsonResponse 51 | */ 52 | ``` 53 | JSON RAW response: 54 | ```php 55 | /** 56 | * Controller method PHPDoc 57 | * 58 | * @OA\ResponseJson({"key":"value"},status=201,description="User data response") 59 | * 60 | * @param Request $request 61 | * @return \Illuminate\Http\JsonResponse 62 | */ 63 | ``` 64 | or 65 | ```php 66 | /** 67 | * Controller method PHPDoc 68 | * 69 | * @OA\ResponseJson({ 70 | * @OA\ResponseParam("key",type="string",example="value",description="Some parameter"), 71 | * },status=201,description="User data response") 72 | * 73 | * @param Request $request 74 | * @return \Illuminate\Http\JsonResponse 75 | */ 76 | ``` 77 | Response from class properties: 78 | ```php 79 | /** 80 | * Controller method PHPDoc 81 | * 82 | * @OA\ResponseClass("App\User",description="User model response") 83 | * 84 | * @param Request $request 85 | * @return \Illuminate\Http\JsonResponse 86 | */ 87 | ``` 88 | In example above response data will be parsed from `App\User` PHPDoc. 89 | 1. `@property` descriptions (property name, type and description) 90 | 2. `@property-read` descriptions (if set `with` property in `ResponseClass` annotation) 91 | 3. `@OA\Property` annotations (property name, type, description, example etc.) 92 | 93 | `@OA\ResponseClass` use cases, 94 | first is standard use but with additional properties 95 | ```php 96 | /** 97 | * @OA\ResponseClass("App\User",with={"profile"},status=201) 98 | */ 99 | ``` 100 | As items list 101 | ```php 102 | /** 103 | * @OA\ResponseClass("App\User",asList=true) 104 | */ 105 | ``` 106 | As paged items list 107 | ```php 108 | /** 109 | * @OA\ResponseClass("App\User",asPagedList=true) 110 | */ 111 | ``` 112 | 113 | Error responses 114 | ```php 115 | /** 116 | * @OA\ResponseError(403) // Forbidden 117 | * @OA\ResponseError(404) // Not found 118 | * @OA\ResponseError(422) // Validation error 119 | */ 120 | ``` 121 | ### Request bodies 122 | Request data is parsed from `::rules()` method of `FormRequest` class, that used in controller method for the route and it's annotations (`@OA\RequestBody`, `@OA\RequestBodyJson`, `@OA\RequestParam`). 123 | From `::rules()` method we can obtain only name and type of parameter and suggest some example, 124 | but if you want fully describe parameters of request body you must place appropriate annotations in `FormRequest` class for route. 125 | #### Examples 126 | ```php 127 | /** 128 | * @OA\RequestBodyJson({ 129 | * @OA\RequestParam("first_name",type="string",description="User name"), 130 | * @OA\RequestParam("email",type="string",description="User email"), 131 | * @OA\RequestParamArray("phones",items="string",description="User phones array"), 132 | * }) 133 | */ 134 | ``` 135 | ### Tags 136 | Tags can be defined in Controller class or method that route uses. 137 | Do not use space ` ` in tag names, link with such tag name will be broken in Swagger UI, so better idea to use dash `-` or underscore `_`, or even just a `CamelCased` tag names. 138 | Tags defined in controller will be applied to ALL controller methods. 139 | ```php 140 | /** 141 | * @OA\Tag("Tag-name") 142 | */ 143 | ``` 144 | ### Secured 145 | This annotation is used to mark route as `secured`, and tells to swagger, that you must provide valid user credentials to access this route. 146 | Place it in controller method. 147 | ```php 148 | /** 149 | * @OA\Secured() 150 | */ 151 | ``` 152 | ### Property 153 | `@OA\Property` annotation is used to describe class properties as an alternative or addition to PHPDoc `@property`. 154 | You can place example of property (if property is an associative array for example) 155 | or fully describe property if you dont want to place `@property` declaration for it. 156 | ```php 157 | /** 158 | * @OA\Property("notification_settings",type="object",example={"marketing":false,"user_actions":true},description="User notification settings") 159 | */ 160 | ``` 161 | ### PropertyIgnore 162 | `@OA\PropertyIgnore` annotation is used to remove given property from object description. 163 | ```php 164 | /** 165 | * @OA\PropertyIgnore("property_name") 166 | */ 167 | ``` 168 | ### Symlink 169 | This annotation can be used to describe symlink to another class (e.g. for response). All data in class PHPDoc in which it appears will be ignored. 170 | 171 | You must use full namespace of annotations, e.g. `OA\Property`. 172 | 173 | Besides, you can import a namespace for better code completion as in example beyond. 174 | ```php 175 | namespace App\Models; 176 | 177 | use OA; 178 | 179 | /** 180 | * Test model class 181 | * 182 | * @OA\Property("id",type="integer",description="Primary key") 183 | * 184 | * @property string $name String name 185 | */ 186 | class TestModel {} 187 | ``` 188 | 189 | ### More 190 | There is abstract annotation class `OA\DescriptionExtender`, you can use it for your own annotations, those must add some information to route's description. 191 | 192 | Example is bellow. 193 | 194 | ```php 195 | value . '`'; 219 | } 220 | } 221 | ``` 222 | You can use annotation from example: 223 | ```php 224 | routeAnnotations($route, $name); 34 | 35 | return ! empty($annotations) ? reset($annotations) : null; 36 | } 37 | 38 | /** 39 | * Get route controller method annotations 40 | * 41 | * @param Route $route 42 | * @param string|null $name 43 | * @return BaseAnnotation[] 44 | */ 45 | protected function routeAnnotations(Route $route, ?string $name = null) :array 46 | { 47 | $ref = $this->routeReflection($route); 48 | if (! $ref instanceof \ReflectionMethod) { 49 | return []; 50 | } 51 | $annotations = $this->methodAnnotations($ref); 52 | if ($name === null) { 53 | return $annotations; 54 | } 55 | $name = ltrim($name); 56 | $result = []; 57 | foreach ($annotations as $annotation) { 58 | if ($annotation instanceof $name) { 59 | $result[] = $annotation; 60 | } 61 | } 62 | 63 | return $result; 64 | } 65 | 66 | /** 67 | * Get route controller annotations 68 | * 69 | * @param Route $route 70 | * @param string|null $name 71 | * @param bool $checkExtending 72 | * @param bool $mergeExtended 73 | * @return BaseAnnotation[] 74 | */ 75 | protected function controllerAnnotations(Route $route, ?string $name = null, bool $checkExtending = false, bool $mergeExtended = false): array 76 | { 77 | $ref = $this->routeReflection($route); 78 | if (! $ref instanceof \ReflectionMethod) { 79 | return []; 80 | } 81 | 82 | $controllerNames = [ 83 | $checkExtending && is_object($route->getController()) ? get_class($route->getController()) : null, // Class from route definition 84 | $ref->class, // Class from reflection, where real method written 85 | ]; 86 | $controllerNames = array_unique(array_filter($controllerNames)); 87 | $annotationsClass = []; 88 | foreach ($controllerNames as $controllerName) { 89 | $annotationsClass[] = $this->classAnnotations($controllerName); 90 | } 91 | 92 | $annotationsClass = array_filter($annotationsClass); 93 | if (empty($annotationsClass)) { 94 | return []; 95 | } 96 | 97 | $annotations = $mergeExtended ? array_merge([], ...$annotationsClass) : reset($annotationsClass); 98 | 99 | if ($name === null) { 100 | return $annotations; 101 | } 102 | $name = ltrim($name); 103 | $result = []; 104 | foreach ($annotations as $annotation) { 105 | if ($annotation instanceof $name) { 106 | $result[] = $annotation; 107 | } 108 | } 109 | 110 | return $result; 111 | } 112 | 113 | /** 114 | * Get class annotation 115 | * 116 | * @param string|object $class 117 | * @param string $name 118 | * @return BaseAnnotation|null 119 | */ 120 | protected function classAnnotation($class, string $name): ?BaseAnnotation 121 | { 122 | $annotations = $this->classAnnotations($class, $name); 123 | 124 | return ! empty($annotations) ? reset($annotations) : null; 125 | } 126 | 127 | /** 128 | * Get class annotations 129 | * 130 | * @param string|object $class 131 | * @param string|null $name 132 | * @return \OA\BaseAnnotation[] 133 | */ 134 | protected function classAnnotations($class, ?string $name = null): array 135 | { 136 | $className = is_string($class) ? $class : get_class($class); 137 | $ref = $this->reflectionClass($className); 138 | if (($annotations = $this->getCachedAnnotations($ref)) === null) { 139 | $annotations = $this->annotationReader()->getClassAnnotations($ref); 140 | $this->setCachedAnnotations($ref, $annotations); 141 | } 142 | if ($name === null) { 143 | return $annotations; 144 | } 145 | $result = []; 146 | foreach ($annotations as $annotation) { 147 | if ($annotation instanceof $name) { 148 | $result[] = $annotation; 149 | } 150 | } 151 | 152 | return $result; 153 | } 154 | 155 | /** 156 | * Get class method annotations 157 | * 158 | * @param \ReflectionMethod|array $ref 159 | * @param string $name 160 | * @return BaseAnnotation|null 161 | */ 162 | protected function methodAnnotation($ref, string $name): ?BaseAnnotation 163 | { 164 | $annotations = $this->methodAnnotations($ref, $name); 165 | 166 | return ! empty($annotations) ? reset($annotations) : null; 167 | } 168 | 169 | /** 170 | * Get class method annotations 171 | * 172 | * @param \ReflectionMethod|array $ref 173 | * @param string|null $name 174 | * @return BaseAnnotation[] 175 | */ 176 | protected function methodAnnotations($ref, ?string $name = null): array 177 | { 178 | if (is_array($ref)) { 179 | $ref = $this->reflectionMethod(...$ref); 180 | } 181 | if (! $ref instanceof \ReflectionMethod) { 182 | return []; 183 | } 184 | if (($annotations = $this->getCachedAnnotations($ref)) === null) { 185 | $annotations = $this->annotationReader()->getMethodAnnotations($ref); 186 | $this->setCachedAnnotations($ref, $annotations); 187 | } 188 | if ($name === null) { 189 | return $annotations; 190 | } 191 | $result = []; 192 | foreach ($annotations as $annotation) { 193 | if ($annotation instanceof $name) { 194 | $result[] = $annotation; 195 | } 196 | } 197 | 198 | return $result; 199 | } 200 | 201 | /** 202 | * Get annotation reader. 203 | * 204 | * @return \Doctrine\Common\Annotations\Reader 205 | */ 206 | protected function annotationReader(): Reader 207 | { 208 | if (! isset($this->_annotationReader)) { 209 | AnnotationRegistry::registerLoader('class_exists'); 210 | $ignored = config('swagger-generator.ignoredAnnotationNames', []); 211 | foreach ($ignored as $item) { 212 | AnnotationReader::addGlobalIgnoredName($item); 213 | } 214 | $this->_annotationReader = new AnnotationReader(); 215 | } 216 | 217 | return $this->_annotationReader; 218 | } 219 | 220 | /** 221 | * Get cached annotations 222 | * 223 | * @param \ReflectionClass|\ReflectionMethod $ref 224 | * @return array|null 225 | */ 226 | private function getCachedAnnotations($ref): ?array 227 | { 228 | [$property, $key] = $this->getRefAnnotationsCacheKeys($ref); 229 | 230 | return Arr::get($this->{$property}, $key, null); 231 | } 232 | 233 | /** 234 | * Set annotations to cache 235 | * 236 | * @param \ReflectionClass|\ReflectionMethod $ref 237 | * @param array $annotations 238 | */ 239 | private function setCachedAnnotations($ref, array $annotations): void 240 | { 241 | [$property, $key] = $this->getRefAnnotationsCacheKeys($ref); 242 | Arr::set($this->{$property}, $key, $annotations); 243 | } 244 | 245 | /** 246 | * Get keys for annotations cache 247 | * 248 | * @param \ReflectionClass|\ReflectionMethod $ref 249 | * @return array 250 | * @see WithAnnotationReader::$_classAnnotations 251 | * @see WithAnnotationReader::$_methodAnnotations 252 | */ 253 | private function getRefAnnotationsCacheKeys($ref): array 254 | { 255 | if ($ref instanceof \ReflectionMethod) { 256 | $keys = ['_methodAnnotations', $ref->class . '::' . $ref->name]; 257 | } else { 258 | $keys = ['_classAnnotations', $ref->name]; 259 | } 260 | 261 | return $keys; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/Commands/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | files = $files; 55 | $this->router = $router; 56 | $this->routes = $router->getRoutes(); 57 | } 58 | 59 | /** 60 | * Handle command. 61 | * 62 | * @return int 63 | */ 64 | public function handle(): int 65 | { 66 | if ($this->isDiag()) { 67 | return $this->handleDiagnose(); 68 | } 69 | $startTime = microtime(); 70 | $filePath = $this->getMainFile(); 71 | $arrayContent = config('swagger-generator.content', []); 72 | $arrayContent = $this->mergeWithFilesContent($arrayContent, config('swagger-generator.contentFilesBefore', [])); 73 | Variable::collectClassReferences(true, true); 74 | $routesData = $this->getRoutesData(); 75 | $arrayContent = $this->describer()->merge($arrayContent, $routesData); 76 | $arrayContent = $this->mergeWithFilesContent($arrayContent, config('swagger-generator.contentFilesAfter', [])); 77 | $definitions = $this->generateAdditionalDefinitions(); 78 | Variable::populateComponentsArrayWithCollectedClassReferences($definitions['components']); 79 | $arrayContent = $this->describer()->merge($arrayContent, $definitions); 80 | $content = $this->describer()->toYml($arrayContent); 81 | $this->files->put($filePath, $content); 82 | $this->getOutput()->success(sprintf("Swagger YML file was generated in '%s'", $filePath)); 83 | $this->printTimeSpent($startTime); 84 | 85 | return 0; 86 | } 87 | 88 | /** 89 | * Handle request in diagnose mode. 90 | * 91 | * @return int 92 | */ 93 | protected function handleDiagnose(): int 94 | { 95 | $startTime = microtime(); 96 | $this->getOutput()->success('Diagnose mode, files will not be generated.'); 97 | $parser = new RoutesParser($this->routes, $this->getOutput()); 98 | $paths = $parser->parse(); 99 | $routesCount = 0; 100 | array_walk($paths, function ($value) use (&$routesCount) { $routesCount += count($value); }); 101 | $this->getOutput()->success(strtr('There are {count} route(s) parsed.', ['{count}' => $routesCount])); 102 | 103 | foreach ($parser->problems as $key => $routes) { 104 | $label = $this->getProblemLabel($key); 105 | $label .= ' (' . count($routes) . ' occurrence(s))'; 106 | $this->getOutput()->warning($label); 107 | if ($this->getOutput()->isVerbose()) { 108 | $table = []; 109 | foreach ($routes as $routeData) { 110 | /** @var Route $route */ 111 | $route = $routeData[0]; 112 | $additional = $routeData[1] ?? null; 113 | $table[] = [ 114 | $route->uri(), 115 | $route->getActionName(), 116 | $additional, 117 | ]; 118 | } 119 | $this->getOutput()->table(['URI', 'Controller', 'Additional'], $table); 120 | } 121 | } 122 | 123 | if (! $this->getOutput()->isVerbose()) { 124 | $this->getOutput()->title('To see additional information use option -v.'); 125 | } 126 | 127 | $this->printTimeSpent($startTime); 128 | 129 | return 0; 130 | } 131 | 132 | /** 133 | * Print time spent from given point. 134 | * 135 | * @param string $startTime 136 | */ 137 | protected function printTimeSpent(string $startTime): void 138 | { 139 | $start = \DateTime::createFromFormat('0.u00 U', $startTime); 140 | $finish = \DateTime::createFromFormat('0.u00 U', microtime()); 141 | $diff = $start->diff($finish); 142 | $this->getOutput()->title('Time spent: ' . $diff->format('%H:%I:%S.%F')); 143 | } 144 | 145 | /** 146 | * Get problem label. 147 | * 148 | * @param string $key 149 | * @return string 150 | */ 151 | protected function getProblemLabel(string $key): string 152 | { 153 | $labels = [ 154 | RoutesParser::PROBLEM_NO_RESPONSE => 'Route has no described response body', 155 | RoutesParser::PROBLEM_ROUTE_CLOSURE => 'Route is handled by closure. Closure not supports annotations.', 156 | RoutesParser::PROBLEM_NO_DOC_CLASS => 'There is not PHPDoc for class.', 157 | RoutesParser::PROBLEM_MISSING_TAG => 'Route "Tags" are not set.', 158 | RoutesParser::PROBLEM_MISSING_PARAM => 'Route "Parameter" not described.', 159 | ]; 160 | 161 | return $labels[$key] ?? ucfirst(str_replace(['-', '_'], ' ', $key)); 162 | } 163 | 164 | /** 165 | * Check that diagnose mode enabled. 166 | * 167 | * @return bool 168 | */ 169 | protected function isDiag(): bool 170 | { 171 | return $this->option('diagnose'); 172 | } 173 | 174 | /** 175 | * Merge data with content of YML files. 176 | * 177 | * @param array $data 178 | * @param array $fileList 179 | * @return array 180 | */ 181 | protected function mergeWithFilesContent(array $data = [], array $fileList = []): array 182 | { 183 | if (empty($fileList)) { 184 | return $data; 185 | } 186 | $filesContent = []; 187 | foreach ($fileList as $fileName) { 188 | if ($this->files->exists($fileName) && ($row = $this->describer()->fromYml($fileName)) !== null) { 189 | $filesContent[] = $row; 190 | } 191 | } 192 | if (empty($filesContent)) { 193 | return $data; 194 | } 195 | 196 | return $this->describer()->merge($data, ...$filesContent); 197 | } 198 | 199 | /** 200 | * Get data from routes parser. 201 | * 202 | * @return array 203 | */ 204 | protected function getRoutesData(): array 205 | { 206 | $parser = new RoutesParser($this->routes, $this->getOutput()); 207 | $paths = $parser->parse(); 208 | $this->sortPaths($paths); 209 | ksort($parser->components['responses']); 210 | ksort($parser->components['requestBodies']); 211 | $data = ['paths' => $paths, 'components' => array_filter($parser->components, fn ($rows) => ! empty($rows))]; 212 | if ($this->getOutput()->isVerbose()) { 213 | $responses = array_keys($parser->components['responses']); 214 | $requests = array_keys($parser->components['requestBodies']); 215 | array_walk($responses, function (&$value) { $value = [$value]; }); 216 | array_walk($requests, function (&$value) { $value = [$value]; }); 217 | $this->getOutput()->table(['Responses'], $responses); 218 | $this->getOutput()->table(['Request Bodies'], $requests); 219 | } 220 | 221 | return $data; 222 | } 223 | 224 | /** 225 | * Generate additional definitions. 226 | * 227 | * @return array 228 | */ 229 | protected function generateAdditionalDefinitions(): array 230 | { 231 | $classes = config('swagger-generator.generateDefinitions', []); 232 | $definitions = []; 233 | foreach ($classes as $item) { 234 | [$className, $classBaseName, $classDescription, $classWith] = $this->normalizeModelDefinitionConfigItem($item); 235 | $classBaseName = $classBaseName ?? class_basename($className); 236 | if ($classDescription === null) { 237 | $docStr = $this->docBlockClass($className); 238 | $classDescription = is_string($docStr) ? $this->getDocSummary($docStr) : null; 239 | } 240 | $variable = Variable::fromDescription([ 241 | 'type' => $className, 242 | 'with' => $classWith, 243 | 'description' => $classDescription, 244 | ]); 245 | $classDefinition = $variable->describe(); 246 | $definitions[$classBaseName] = $classDefinition; 247 | } 248 | 249 | return ! empty($definitions) ? ['components' => ['schemas' => $definitions]] : []; 250 | } 251 | 252 | /** 253 | * Generate additional definitions. 254 | * 255 | * @param string|array $itemRaw 256 | * @return array 257 | */ 258 | private function normalizeModelDefinitionConfigItem(array|string $itemRaw): array 259 | { 260 | $item = ['', null, null, []]; 261 | 262 | if (is_string($itemRaw)) { 263 | $item[0] = $itemRaw; 264 | } elseif (is_array($itemRaw)) { 265 | if (isset($itemRaw[3])) { 266 | $itemRaw[3] = Arr::wrap($itemRaw[3]); 267 | } 268 | $item = $itemRaw + $item; 269 | } 270 | 271 | return $item; 272 | } 273 | 274 | /** 275 | * Sort router paths. 276 | * 277 | * @param array $paths 278 | */ 279 | protected function sortPaths(array &$paths) 280 | { 281 | // ksort($paths); 282 | $byTags = []; 283 | // Group by first tag 284 | foreach ($paths as $path => $route) { 285 | // Sort by method in same path 286 | uksort($route, function ($a, $b) { 287 | $methods = ['head', 'get', 'post', 'patch', 'put', 'delete']; 288 | $aPos = array_search($a, $methods, true); 289 | $bPos = array_search($b, $methods, true); 290 | 291 | return $aPos < $bPos ? -1 : 1; 292 | }); 293 | $firstMethod = reset($route); 294 | $tag = reset($firstMethod['tags']); 295 | $byTags[$tag][$path] = $route; 296 | } 297 | // Sort tags 298 | ksort($byTags); 299 | // Rewrite paths array 300 | $paths = []; 301 | foreach ($byTags as $routes) { 302 | $paths = $this->describer()->merge($paths, $routes); 303 | } 304 | } 305 | 306 | /** 307 | * Get path to main yml file. 308 | * 309 | * @return string 310 | */ 311 | protected function getMainFile() 312 | { 313 | $path = config('swagger-generator.output.path'); 314 | // absolute path 315 | if (! str_starts_with($path, '/')) { 316 | $path = app()->basePath($path); 317 | } 318 | if (! $this->files->exists($path)) { 319 | $this->files->makeDirectory($path, 0755, true); 320 | } 321 | 322 | return $path . DIRECTORY_SEPARATOR . config('swagger-generator.output.file_name'); 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /oa/Response.php: -------------------------------------------------------------------------------- 1 | _setProperties = $this->configureSelf($values, 'content'); 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function toArray(): array 59 | { 60 | if ($this->isList()) { 61 | $contentRaw = $this->describer()->describe(['']); 62 | Arr::set($contentRaw, 'items', $this->getContent()); 63 | } else { 64 | $contentRaw = $this->getContent(); 65 | } 66 | $content = $this->wrapInDefaultResponse($contentRaw); 67 | static::handleIncompatibleTypeKeys($content); 68 | 69 | return [ 70 | 'description' => $this->description ?? $this->getDefaultDescription(), 71 | 'content' => [ 72 | $this->contentType => [ 73 | 'schema' => $content, 74 | ], 75 | ], 76 | ]; 77 | } 78 | 79 | /** 80 | * Get component key 81 | * 82 | * @return string|null 83 | */ 84 | public function getComponentKey(): ?string 85 | { 86 | return null; 87 | } 88 | 89 | /** 90 | * Check that response has Data 91 | * 92 | * @return bool 93 | */ 94 | public function hasData(): bool 95 | { 96 | return ! $this->_hasNoData; 97 | } 98 | 99 | /** 100 | * Get content array 101 | * 102 | * @return array|null 103 | */ 104 | protected function getContent(): ?array 105 | { 106 | $this->_hasNoData = ! $this->wasSetInConstructor('content') && empty($this->content) 107 | ? true : $this->_hasNoData; 108 | 109 | if (($contentByAnnotations = $this->getContentByAnnotations()) !== null) { 110 | return $contentByAnnotations; 111 | } 112 | 113 | return $this->content !== null ? $this->describer()->describe($this->content) : null; 114 | } 115 | 116 | /** 117 | * Get content generated by used annotations. 118 | * 119 | * @return array|null 120 | */ 121 | protected function getContentByAnnotations(): ?array 122 | { 123 | if (! is_array($this->content) || Arr::isAssoc($this->content)) { 124 | return null; 125 | } 126 | 127 | $newContent = []; 128 | foreach ($this->content as $contentKey => $contentRow) { 129 | if ($contentRow instanceof ResponseParam) { 130 | if (! isset($contentRow->name)) { 131 | throw new \RuntimeException("Attribute 'name' in ResponseParam annotation is required."); 132 | } 133 | $newContent[$contentRow->name] = $contentRow->toArray(); 134 | } 135 | } 136 | 137 | if (! empty($newContent)) { 138 | return [ 139 | 'type' => Variable::SW_TYPE_OBJECT, 140 | 'properties' => $newContent, 141 | ]; 142 | } 143 | 144 | return null; 145 | } 146 | 147 | /** 148 | * Check that properties was set in construction. 149 | * 150 | * @param array|string $properties Properties list 151 | * @param bool $any Check if any of given properties was set, otherwise checks all given properties list 152 | * @return bool 153 | */ 154 | protected function wasSetInConstructor(array|string $properties, bool $any = false): bool 155 | { 156 | $properties = (array)$properties; 157 | if (count($properties) === 1) { 158 | return in_array(reset($properties), $this->_setProperties, true); 159 | } 160 | $intersection = array_intersect($this->_setProperties, $properties); 161 | 162 | return ($any && ! empty($intersection)) || count($intersection) === count($properties); 163 | } 164 | 165 | /** 166 | * Get object string representation 167 | * 168 | * @return string 169 | */ 170 | public function __toString() 171 | { 172 | return (string)$this->status; 173 | } 174 | 175 | /** 176 | * Wrap response content in default response 177 | * 178 | * @param mixed $content 179 | * @return array|mixed 180 | */ 181 | protected function wrapInDefaultResponse(mixed $content = null): mixed 182 | { 183 | $content = $content ?? $this->content; 184 | $responseData = static::getDefaultResponse($this->contentType, $this->status, $this->isSuccessful); 185 | if ($responseData === null) { 186 | return $content; 187 | } 188 | [$responseRaw, $resultKey] = array_values($responseData); 189 | if (($this->asPagedList || $this->asCursorPagedList) && ($this->isSuccessful ?? static::isSuccessStatus($this->status))) { 190 | if ($this->asPagedList) { 191 | $responseRaw['pagination'] = static::getPagerExample(); 192 | } elseif ($this->asCursorPagedList) { 193 | $responseRaw['pagination'] = static::getCursorPagerExample(); 194 | } 195 | } 196 | $response = $this->describer()->describe($responseRaw); 197 | $content !== null ? Arr::set($response, $resultKey, $content) : Arr::forget($response, $resultKey); 198 | 199 | return $response; 200 | } 201 | 202 | /** 203 | * Get default response by content type [response, result_array_key]. 204 | * 205 | * @param string $contentType 206 | * @param int $status 207 | * @param bool|null $isSuccessful Override check by status for success/error response 208 | * @return mixed|null 209 | */ 210 | protected static function getDefaultResponse(string $contentType, int $status = 200, ?bool $isSuccessful = null): mixed 211 | { 212 | $key = ($isSuccessful ?? static::isSuccessStatus($status)) ? 'ok' : 'error'; 213 | $responses = [ 214 | 'application/json' => [ 215 | 'ok' => [ 216 | 'response' => [ 217 | 'success' => true, 218 | 'message' => 'OK', 219 | 'result' => false, 220 | ], 221 | 'resultKey' => 'properties.result', 222 | ], 223 | 'error' => [ 224 | 'response' => [ 225 | 'success' => false, 226 | 'message' => 'Error', 227 | 'errors' => [], 228 | ], 229 | 'resultKey' => 'properties.errors', 230 | ], 231 | ], 232 | ]; 233 | 234 | $responseKey = $contentType . '.' . $key; 235 | if (($responseData = Arr::get($responses, $responseKey, null)) === null) { 236 | return null; 237 | } 238 | 239 | return $responseData; 240 | } 241 | 242 | /** 243 | * Check that status is successful. 244 | * 245 | * @param int|string $status 246 | * @return bool 247 | */ 248 | protected static function isSuccessStatus($status): bool 249 | { 250 | $statusInt = (int)$status; 251 | 252 | return $statusInt >= 200 && $statusInt < 400; 253 | } 254 | 255 | /** 256 | * Determines whether response is a list of items. 257 | * 258 | * @return bool 259 | */ 260 | protected function isList(): bool 261 | { 262 | return $this->asList || $this->asPagedList || $this->asCursorPagedList; 263 | } 264 | 265 | /** 266 | * Get pager example. 267 | * 268 | * @return array 269 | */ 270 | protected static function getPagerExample(): array 271 | { 272 | $data = array_fill(0, 100, null); 273 | $pager = new \Illuminate\Pagination\LengthAwarePaginator($data, 100, 10, 2); 274 | 275 | return Arr::except($pager->toArray(), ['items', 'data', 'links']); 276 | } 277 | 278 | /** 279 | * Get cursor pager example. 280 | * 281 | * @return array 282 | */ 283 | protected static function getCursorPagerExample(): array 284 | { 285 | $cursor = new \Illuminate\Pagination\Cursor(['id' => 20]); 286 | $data = array_map(function ($v) { return ['id' => $v]; }, array_keys(array_fill(1, 100, null))); 287 | $pager = new \Illuminate\Pagination\CursorPaginator($data, 10, $cursor); 288 | 289 | return Arr::except($pager->toArray(), ['data']); 290 | } 291 | 292 | /** 293 | * Get default description. 294 | * 295 | * @return string 296 | */ 297 | protected function getDefaultDescription(): string 298 | { 299 | $list = static::getDefaultStatusDescriptions(); 300 | 301 | return $list[$this->status] ?? ''; 302 | } 303 | 304 | /** 305 | * Get default statuses descriptions 306 | * 307 | * @return array 308 | */ 309 | protected static function getDefaultStatusDescriptions(): array 310 | { 311 | return [ 312 | 200 => 'OK', 313 | 201 => 'Created', 314 | 202 => 'Accepted', 315 | 203 => 'Non-authoritative Information', 316 | 204 => 'No Content', 317 | 205 => 'Reset Content', 318 | 206 => 'Partial Content', 319 | 207 => 'Multi-Status', 320 | 208 => 'Already Reported', 321 | 226 => 'IM Used', 322 | 300 => 'Multiple Choices', 323 | 301 => 'Moved Permanently', 324 | 302 => 'Found', 325 | 303 => 'See Other', 326 | 304 => 'Not Modified', 327 | 305 => 'Use Proxy', 328 | 307 => 'Temporary Redirect', 329 | 308 => 'Permanent Redirect', 330 | 400 => 'Bad Request', 331 | 401 => 'Unauthorized', 332 | 402 => 'Payment Required', 333 | 403 => 'Forbidden', 334 | 404 => 'Not Found', 335 | 405 => 'Method Not Allowed', 336 | 406 => 'Not Acceptable', 337 | 407 => 'Proxy Authentication Required', 338 | 408 => 'Request Timeout', 339 | 409 => 'Conflict', 340 | 410 => 'Gone', 341 | 411 => 'Length Required', 342 | 412 => 'Precondition Failed', 343 | 413 => 'Payload Too Large', 344 | 414 => 'Request-URI Too Long', 345 | 415 => 'Unsupported Media Type', 346 | 416 => 'Requested Range Not Satisfiable', 347 | 417 => 'Expectation Failed', 348 | 418 => 'I\'m a teapot', 349 | 421 => 'Misdirected Request', 350 | 422 => 'Unprocessable Entity', 351 | 423 => 'Locked', 352 | 424 => 'Failed Dependency', 353 | 426 => 'Upgrade Required', 354 | 428 => 'Precondition Required', 355 | 429 => 'Too Many Requests', 356 | 431 => 'Request Header Fields Too Large', 357 | 444 => 'Connection Closed Without Response', 358 | 451 => 'Unavailable For Legal Reasons', 359 | 499 => 'Client Closed Request', 360 | 500 => 'Internal Server Error', 361 | 501 => 'Not Implemented', 362 | 502 => 'Bad Gateway', 363 | 503 => 'Service Unavailable', 364 | 504 => 'Gateway Timeout', 365 | 505 => 'HTTP Version Not Supported', 366 | 506 => 'Variant Also Negotiates', 367 | 507 => 'Insufficient Storage', 368 | 508 => 'Loop Detected', 369 | 510 => 'Not Extended', 370 | 511 => 'Network Authentication Required', 371 | 599 => 'Network Connect Timeout Error', 372 | ]; 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /oa/BaseValueDescribed.php: -------------------------------------------------------------------------------- 1 | configureSelf($values, 'name'); 86 | $this->processType(); 87 | } 88 | 89 | /** 90 | * Check that variable name is nested (with dots) 91 | * @return bool 92 | */ 93 | public function isNested(): bool 94 | { 95 | return $this->name !== null && str_contains($this->name, '.'); 96 | } 97 | 98 | /** 99 | * Get name of the parent for nested. 100 | * 101 | * @return string|null 102 | */ 103 | public function getNestedParentName(): ?string 104 | { 105 | [ , , , $parentName] = $this->getNestedPaths(); 106 | 107 | return $parentName; 108 | } 109 | 110 | /** 111 | * Get paths for nested names. 112 | * 113 | * @return string[] 114 | */ 115 | public function getNestedPaths(): array 116 | { 117 | $nameParts = explode('.', $this->name); 118 | if (count($nameParts) === 1) { 119 | return [null, null, $this->name, null]; 120 | } 121 | $namePartsParent = $nameParts; 122 | array_pop($namePartsParent); 123 | $path = $this->makePathFromKeysArray($nameParts); 124 | $pathParent = $this->makePathFromKeysArray($namePartsParent); 125 | $lastName = last($nameParts); 126 | 127 | return [ 128 | $path, // Full path 129 | $pathParent, // Path to the parent 130 | $lastName !== '*' ? $lastName : null, // Deepest variable name 131 | implode('.', $namePartsParent), // Parent name 132 | ]; 133 | } 134 | 135 | /** 136 | * Sets this array content to target by obtained key 137 | * 138 | * @param array $target 139 | */ 140 | public function toArrayRecursive(array &$target): void 141 | { 142 | $nameArr = explode('.', $this->name); 143 | $currentTarget = &$target; 144 | while ($key = array_shift($nameArr)) { 145 | $isArray = $key === '*'; 146 | $hasNested = ! empty($nameArr); 147 | if ($isArray) { 148 | if (! isset($currentTarget['items'])) { 149 | $currentTarget['type'] = 'array'; 150 | $currentTarget['items'] = []; 151 | } 152 | $currentTarget = &$currentTarget['items']; 153 | } else { 154 | if (! isset($currentTarget['properties'])) { 155 | $currentTarget = ['type' => 'object', 'properties' => []]; 156 | } 157 | $currentTarget['properties'][$key] = $currentTarget['properties'][$key] ?? []; 158 | $currentTarget = &$currentTarget['properties'][$key]; 159 | } 160 | 161 | if (! $hasNested) { 162 | $currentTarget = empty($currentTarget) ? $this->toArray() : $this->describer()->merge($currentTarget, $this->toArray()); 163 | } 164 | } 165 | } 166 | 167 | /** 168 | * Get object string representation. 169 | * 170 | * @return string 171 | */ 172 | public function __toString() 173 | { 174 | return (string)$this->name; 175 | } 176 | 177 | /** 178 | * @inheritdoc 179 | */ 180 | public function toArray(): array 181 | { 182 | $exampleRequired = $this->isExampleRequired(); 183 | $swType = $this->type ?? $this->guessType(); 184 | $data = []; 185 | $attributesMap = ['example' => 'exampleProcessed']; 186 | $attributes = $this->getDumpedKeys(); 187 | $excludeKeys = $this->getExcludedKeys(); 188 | $excludeEmptyKeys = $this->getExcludedEmptyKeys(); 189 | if (in_array('type', $attributes, true)) { 190 | $attributes = array_diff($attributes, ['type']); 191 | $data['type'] = $swType; 192 | } 193 | foreach ($attributes as $key) { 194 | $sourceKey = $attributesMap[$key] ?? $key; 195 | if (($attrValue = $this->{$sourceKey}) !== null) { 196 | $data[$key] = $attrValue; 197 | } 198 | } 199 | // Add properties to object 200 | if ($swType === Variable::SW_TYPE_OBJECT) { 201 | $data['properties'] = $this->guessProperties(); 202 | // Remove `example` data if we have successfully got the properties 203 | if (! empty($data['properties'])) { 204 | unset($data['example']); 205 | $exampleRequired = false; 206 | } 207 | // Add items key to array 208 | } elseif ($swType === Variable::SW_TYPE_ARRAY) { 209 | $this->items = $this->items ?? 'string'; 210 | $data['items'] = ['type' => $this->items]; 211 | if (isset($data['format'])) { 212 | $data['items']['format'] = $data['format']; 213 | Arr::forget($data, ['format']); 214 | } 215 | } 216 | // Write example if needed 217 | if ($exampleRequired && ! isset($data['example'])) { 218 | $example = $this->describer()->example(null, $this->type, $this->name); 219 | // Get example one more time for PHP type (except PHP_ARRAY, SW_TYPE_OBJECT) 220 | $example = $example === null && $this->type !== Variable::SW_TYPE_OBJECT && ($phpType = $this->describer()->phpType($this->type)) !== $this->type 221 | ? $this->describer()->example($phpType, null, $this->name) 222 | : $example; 223 | if ($example !== null && $this->type !== null && $this->describer()->isValueSuitableForType($this->type, $example)) { 224 | $data['example'] = Arr::get($data, 'format') !== Variable::SW_FORMAT_BINARY ? $example : 'binary'; 225 | } 226 | } 227 | // Exclude undesirable keys 228 | if (! empty($excludeKeys)) { 229 | $data = Arr::except($data, $excludeKeys); 230 | } 231 | // Exclude undesirable keys those are empty 232 | if (! empty($excludeEmptyKeys)) { 233 | $data = array_filter($data, function ($value, $key) use ($excludeEmptyKeys) { 234 | return ! in_array($key, $excludeEmptyKeys, true) || ! empty($value); 235 | }, ARRAY_FILTER_USE_BOTH); 236 | } 237 | // Remap schema children keys 238 | if ($this->isSchemaTypeUsed()) { 239 | $schemaKeys = ['type', 'format', 'items', 'enum', 'example']; 240 | foreach ($schemaKeys as $schemaKey) { 241 | if (($dataValue = $data[$schemaKey] ?? null) === null) { 242 | continue; 243 | } 244 | $dataValue = $dataValue === static::NULL_VALUE ? null : $dataValue; 245 | Arr::set($data, 'schema.' . $schemaKey, $dataValue); 246 | Arr::forget($data, $schemaKey); 247 | } 248 | // Rewrite `example` key "NULL" => null 249 | } elseif (isset($data['example'])) { 250 | $data['example'] = $data['example'] === static::NULL_VALUE ? null : $data['example']; 251 | } 252 | 253 | return $data; 254 | } 255 | 256 | /** 257 | * Check that object has enum set 258 | * 259 | * @return bool 260 | */ 261 | protected function hasEnum(): bool 262 | { 263 | return is_array($this->enum) && ! empty($this->enum); 264 | } 265 | 266 | /** 267 | * Make a path from keys array. 268 | * 269 | * @param array $keys 270 | * @return string 271 | */ 272 | protected function makePathFromKeysArray(array $keys): string 273 | { 274 | $first = array_shift($keys); 275 | $keys = array_map(fn ($k) => $k === '*' ? '.items' : '.properties.' . $k, $keys); 276 | array_unshift($keys, $first); 277 | 278 | return implode('', $keys); 279 | } 280 | 281 | /** 282 | * Get example with check by enum 283 | * 284 | * @return mixed 285 | */ 286 | protected function getExampleProcessed(): mixed 287 | { 288 | $example = $this->example; 289 | if ($this->hasEnum()) { 290 | $example = $this->example !== null && in_array($this->example, $this->enum, false) ? $this->example : reset($this->enum); 291 | } 292 | 293 | return $example; 294 | } 295 | 296 | /** 297 | * Guess object properties key 298 | * 299 | * @return array 300 | */ 301 | protected function guessProperties(): array 302 | { 303 | $example = $this->getExampleProcessed(); 304 | $described = []; 305 | // By given example 306 | if ($example !== null) { 307 | $described = Variable::fromExample($example, $this->name, $this->description)->describe(); 308 | // By PHP type 309 | } elseif ($this->_phpType !== null && $this->describer()->isTypeClassName($this->_phpType)) { 310 | $described = Variable::fromDescription(['type' => $this->_phpType])->describe(false); 311 | } 312 | 313 | return ! empty($described['properties']) ? $described['properties'] : []; 314 | } 315 | 316 | /** 317 | * Guess var type by example. 318 | * 319 | * @return string|null 320 | */ 321 | protected function guessType(): ?string 322 | { 323 | $example = $this->getExampleProcessed(); 324 | if ($example !== null) { 325 | return $this->describer()->swaggerTypeByExample($example); 326 | } 327 | 328 | return $this->type; 329 | } 330 | 331 | /** 332 | * Process type in object. 333 | */ 334 | protected function processType(): void 335 | { 336 | if ($this->type === null) { 337 | return; 338 | } 339 | $this->_phpType = $this->type; 340 | // int[], string[] etc. 341 | if (($isArray = $this->describer()->isTypeArray($this->type)) === true) { 342 | $this->_phpType = $this->type; 343 | $this->items = $this->items ?? $this->describer()->normalizeType($this->type, true); 344 | } 345 | // Convert PHP type to Swagger and vise versa 346 | if ($this->isPhpType($this->type)) { 347 | $this->type = $this->describer()->swaggerType($this->type); 348 | } elseif ($this->describer()->isTypeClassName($this->type)) { 349 | $this->_phpType = $this->type; 350 | $this->type = Variable::SW_TYPE_OBJECT; 351 | } else { 352 | $this->_phpType = $this->describer()->phpType($this->type); 353 | } 354 | } 355 | 356 | /** 357 | * Check that given type is PHP type. 358 | * 359 | * @param string $type 360 | * @return bool 361 | */ 362 | protected function isPhpType(string $type): bool 363 | { 364 | if ($this->describer()->isTypeArray($type)) { 365 | return true; 366 | } 367 | $swType = $this->describer()->swaggerType($type); 368 | 369 | return $swType !== $type; 370 | } 371 | 372 | /** 373 | * Definition must include `schema` key and type, items, enum... keys must be present under that key. 374 | * 375 | * @return bool 376 | */ 377 | protected function isSchemaTypeUsed(): bool 378 | { 379 | return false; 380 | } 381 | 382 | /** 383 | * Example required for this annotation. 384 | * 385 | * @return bool 386 | */ 387 | protected function isExampleRequired(): bool 388 | { 389 | return false; 390 | } 391 | 392 | /** 393 | * Get keys that can be dumped to array. 394 | * 395 | * @return array 396 | */ 397 | protected function getDumpedKeys(): array 398 | { 399 | return [ 400 | 'name', 'type', 'format', 'description', 401 | 'example', 402 | 'required', 'nullable', 'enum', 403 | 'minimum', 'maximum', 'minLength', 'maxLength', 404 | ]; 405 | } 406 | 407 | /** 408 | * Get keys that must be excluded. 409 | * 410 | * @return array 411 | */ 412 | protected function getExcludedKeys(): array 413 | { 414 | return []; 415 | } 416 | 417 | /** 418 | * Get keys that must be excluded if they are empty. 419 | * 420 | * @return array 421 | */ 422 | protected function getExcludedEmptyKeys(): array 423 | { 424 | return []; 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /src/Describer/WithTypeParser.php: -------------------------------------------------------------------------------- 1 | 'integer', 22 | 'bool' => 'boolean', 23 | ]; 24 | /** @var array List of simplified class types */ 25 | protected array $classSimpleTypes = [ 26 | \Illuminate\Support\Carbon::class => 'string', 27 | \Carbon\Carbon::class => 'string', 28 | ]; 29 | //TODO: Combine rules description and example generation 30 | /** @var array Rules data (with PHP type and variable names) */ 31 | protected array $varRules = [ 32 | 'url' => [ 33 | 'type' => 'string', 34 | 'names' => [ 35 | 'url', 36 | 'site', 37 | ], 38 | ], 39 | 'numeric' => [ 40 | 'type' => 'float', 41 | 'names' => [], 42 | ], 43 | 'image' => [ 44 | 'type' => 'string', 45 | 'names' => [ 46 | 'logo', 47 | 'avatar', 48 | 'image', 49 | 'attachment_url', 50 | 'file_url', 51 | ], 52 | ], 53 | 'email' => [ 54 | 'type' => 'string', 55 | 'names' => [ 56 | 'email', 57 | 'mail', 58 | ], 59 | ], 60 | 'password' => [ 61 | 'type' => 'string', 62 | 'names' => [ 63 | 'password', 64 | 'password_confirm', 65 | 'pass', 66 | 'new_password', 67 | 'password_new', 68 | ], 69 | ], 70 | 'token' => [ 71 | 'type' => 'string', 72 | 'names' => [ 73 | 'token', 74 | 'access_token', 75 | 'email_token', 76 | 'remember_token', 77 | 'service_token', 78 | ], 79 | ], 80 | 'domain_name' => [ 81 | 'type' => 'string', 82 | 'names' => [ 83 | 'domain', 84 | 'domain_name', 85 | 'domainName', 86 | 'site', 87 | ], 88 | ], 89 | 'service_name' => [ 90 | 'type' => 'string', 91 | 'names' => [ 92 | 'service_name', 93 | 'serviceName', 94 | ], 95 | ], 96 | 'phone' => [ 97 | 'type' => 'string', 98 | 'names' => [ 99 | 'phone', 100 | 'phone_number', 101 | 'phone_numbers', 102 | 'phones', 103 | ], 104 | ], 105 | 'company_name' => [ 106 | 'type' => 'string', 107 | 'names' => [ 108 | 'company_name', 109 | 'companyName', 110 | 'company', 111 | ], 112 | ], 113 | 'first_name' => [ 114 | 'type' => 'string', 115 | 'names' => [ 116 | 'first_name', 117 | 'firstName', 118 | ], 119 | ], 120 | 'last_name' => [ 121 | 'type' => 'string', 122 | 'names' => [ 123 | 'last_name', 124 | 'lastName', 125 | ], 126 | ], 127 | 'text' => [ 128 | 'type' => 'string', 129 | 'names' => [ 130 | 'description', 131 | ], 132 | ], 133 | 'textShort' => [ 134 | 'type' => 'string', 135 | 'names' => [ 136 | 'title', 137 | ], 138 | ], 139 | 'address' => [ 140 | 'type' => 'string', 141 | 'names' => [ 142 | 'address', 143 | 'post_address', 144 | 'postAddress', 145 | ], 146 | ], 147 | 'date' => [ 148 | 'type' => 'string', 149 | 'names' => [], 150 | ], 151 | 'date_format' => [ 152 | 'type' => 'string', 153 | 'names' => [], 154 | ], 155 | ]; 156 | 157 | protected ?array $varRuleNames = null; 158 | protected ?string $varRuleNamesSortedRegex = null; 159 | protected array $classExistChecks = []; 160 | 161 | /** 162 | * Check that given type is basic 163 | * 164 | * @param string $type 165 | * @return bool 166 | */ 167 | public function isBasicType(string $type): bool 168 | { 169 | $type = $this->normalizeType($type, true); 170 | 171 | return in_array($type, $this->basicTypes, true); 172 | } 173 | 174 | /** 175 | * Check that given type is array of types 176 | * 177 | * @param string $type 178 | * @param bool $normalize 179 | * @return bool 180 | */ 181 | public function isTypeArray(string $type, bool $normalize = true): bool 182 | { 183 | $type = $normalize ? $this->normalizeType($type) : $type; 184 | 185 | return str_contains($type, '[]'); 186 | } 187 | 188 | /** 189 | * Check that given type is a class name 190 | * 191 | * @param string $type 192 | * @return bool 193 | */ 194 | public function isTypeClassName(string $type): bool 195 | { 196 | if (isset($this->classExistChecks[$type])) { 197 | return $this->classExistChecks[$type]; 198 | } 199 | $typeClean = $this->normalizeType($type, true); 200 | 201 | return $this->classExistChecks[$type] = $this->classExistChecks[$typeClean] = 202 | (! in_array($typeClean, $this->basicTypes, true) && (class_exists($typeClean) || interface_exists($typeClean))); 203 | } 204 | 205 | /** 206 | * Normalize type name. 207 | * 208 | * @param string $type 209 | * @param bool $stripArray 210 | * @return string 211 | */ 212 | public function normalizeType(string $type, bool $stripArray = false): string 213 | { 214 | [$typeNormalized] = $this->normalizeTypeAndNullableFlag($type, $stripArray); 215 | 216 | return $typeNormalized; 217 | } 218 | 219 | /** 220 | * Normalize the PHP type and return `nullable` flag. 221 | * 222 | * @param string $type 223 | * @param bool $stripArray 224 | * @return array 225 | */ 226 | public function normalizeTypeAndNullableFlag(string $type, bool $stripArray = false): array 227 | { 228 | [$typeParsed, $nullable] = $this->getNormalizedPhpTypeInfo($type); 229 | if ($stripArray && $this->isTypeArray($typeParsed, false)) { 230 | $typeParsed = substr($typeParsed, 0, -2); 231 | } 232 | $typeLower = strtolower($typeParsed); 233 | if (($typeSyn = $this->basicTypesSyn[$typeLower] ?? null) !== null) { 234 | return [$typeSyn, $nullable]; 235 | } 236 | if (str_contains($typeParsed, '\\') || class_exists($typeParsed) || interface_exists($typeParsed)) { 237 | return [ltrim($typeParsed, '\\'), $nullable]; 238 | } 239 | 240 | return [$typeLower, $nullable]; 241 | } 242 | 243 | /** 244 | * Get normalized type info. 245 | * 246 | * @param string $type Type to parse 247 | * @param string $defaultType Default type if nothing was found 248 | * @return array{string,bool} Type, Is_Nullable 249 | */ 250 | protected function getNormalizedPhpTypeInfo(string $type, string $defaultType = 'string'): array 251 | { 252 | $types = strpos($type, '|') ? explode('|', $type) : [$type]; 253 | $nullTypes = ['null', 'NULL', 'Null']; 254 | $nullable = ! empty(array_intersect($nullTypes, $types)); 255 | $typesFiltered = array_diff($types, $nullTypes); 256 | 257 | return [! empty($typesFiltered) ? reset($typesFiltered) : $defaultType, $nullable]; 258 | } 259 | 260 | /** 261 | * Get swagger type by example variable 262 | * 263 | * @param mixed $example 264 | * @return string|null 265 | */ 266 | public function swaggerTypeByExample(mixed $example): ?string 267 | { 268 | if ($example === null) { 269 | return null; 270 | } 271 | $swType = $this->swaggerType(gettype($example)); 272 | if ($swType === Variable::SW_TYPE_ARRAY && Arr::isAssoc($example)) { 273 | $swType = Variable::SW_TYPE_OBJECT; 274 | } 275 | 276 | return $swType; 277 | } 278 | 279 | /** 280 | * Get swagger type by given PHP type 281 | * 282 | * @param string $phpType 283 | * @return string|null 284 | */ 285 | public function swaggerType(string $phpType): ?string 286 | { 287 | if ($this->isTypeArray($phpType)) { 288 | $phpType = 'array'; 289 | } elseif ($this->isTypeClassName($phpType)) { 290 | $phpType = $this->simplifyClassName($phpType); 291 | } 292 | 293 | return match ($phpType) { 294 | 'string', 'null' => Variable::SW_TYPE_STRING, 295 | 'int', 'integer' => Variable::SW_TYPE_INTEGER, 296 | 'float', 'double' => Variable::SW_TYPE_NUMBER, 297 | 'object' => Variable::SW_TYPE_OBJECT, 298 | 'array' => Variable::SW_TYPE_ARRAY, 299 | default => $phpType, 300 | }; 301 | } 302 | 303 | /** 304 | * Get PHP type by given Swagger type 305 | * 306 | * @param string|null $swType 307 | * @return string|null 308 | */ 309 | public function phpType(?string $swType): ?string 310 | { 311 | return match ($swType) { 312 | Variable::SW_TYPE_OBJECT => 'array', 313 | Variable::SW_TYPE_NUMBER => 'float', 314 | default => $swType, 315 | }; 316 | } 317 | 318 | /** 319 | * Simplify class name to basic type 320 | * 321 | * @param string $className 322 | * @return string 323 | */ 324 | public function simplifyClassName(string $className): string 325 | { 326 | $className = ltrim($className, '\\'); 327 | 328 | return $this->classSimpleTypes[$className] ?? $className; 329 | } 330 | 331 | /** 332 | * Determines whether a value is suitable for given swagger type. 333 | * 334 | * @param string $swType Swagger type 335 | * @param mixed $value Value to check 336 | * @param bool $excludeEmpty Exclude empty values for array and object 337 | * @return bool 338 | */ 339 | public function isValueSuitableForType(string $swType, mixed $value, bool $excludeEmpty = true): bool 340 | { 341 | return match ($swType) { 342 | Variable::SW_TYPE_OBJECT => is_array($value) && (! $excludeEmpty || ! empty($value)) && array_keys($value) !== array_keys(array_values($value)), 343 | Variable::SW_TYPE_ARRAY => is_array($value) && (! $excludeEmpty || ! empty($value)) && array_keys($value) === array_keys(array_values($value)), 344 | Variable::SW_TYPE_NUMBER => is_numeric($value), 345 | Variable::SW_TYPE_INTEGER => is_int($value), 346 | Variable::SW_TYPE_STRING => is_string($value), 347 | Variable::SW_TYPE_BOOLEAN => is_bool($value), 348 | default => false, 349 | }; 350 | } 351 | 352 | /** 353 | * Get possible rule for a variable name 354 | * 355 | * @param string|null $varName 356 | * @param string|null $default 357 | * @return string|null 358 | */ 359 | protected function getVariableRule(?string $varName, ?string $default = null): ?string 360 | { 361 | if ($varName === null) { 362 | return null; 363 | } 364 | $cleanName = $this->cleanupVariableName($varName); 365 | $varNames = $cleanName !== null && $cleanName !== $varName ? [$varName, $cleanName] : [$varName]; 366 | foreach ($varNames as $name) { 367 | foreach ($this->varRules as $rule => $ruleData) { 368 | if (in_array($name, $ruleData['names'], true)) { 369 | return $rule; 370 | } 371 | } 372 | } 373 | 374 | return $default; 375 | } 376 | 377 | /** 378 | * Get rule type (for php) 379 | * 380 | * @param string $rule 381 | * @return string|null 382 | */ 383 | protected function getRuleType(string $rule): ?string 384 | { 385 | if (isset($this->varRules[$rule])) { 386 | return $this->varRules[$rule]['type']; 387 | } 388 | 389 | return $this->isBasicType($rule) ? $rule : null; 390 | } 391 | 392 | /** 393 | * Cleanup variable name (if nested or have suffix appended) 394 | * 395 | * @param string $name 396 | * @return string 397 | */ 398 | protected function cleanupVariableName(string $name): string 399 | { 400 | $suffixes = ['_confirm', '_original', '_example', '_new', '_old']; 401 | // Name is nested 402 | if (str_contains($name, '.')) { 403 | $nameExp = explode('.', $name); 404 | /** @noinspection CallableParameterUseCaseInTypeContextInspection */ 405 | $name = end($nameExp); 406 | } 407 | foreach ($suffixes as $suffix) { 408 | $len = strlen($suffix); 409 | if (substr($name, -$len) === $suffix) { 410 | $name = substr($name, 0, -$len); 411 | break; 412 | } 413 | } 414 | // Check for ending 415 | $regex = $this->getRulesPossibleVarNamesRegex(); 416 | if (preg_match($regex, $name, $matches)) { 417 | return end($matches); 418 | } 419 | 420 | return $name; 421 | } 422 | 423 | /** 424 | * Get regEx for matching var name with possible var names in rules. 425 | * 426 | * @return string 427 | */ 428 | protected function getRulesPossibleVarNamesRegex(): string 429 | { 430 | if ($this->varRuleNamesSortedRegex !== null) { 431 | return $this->varRuleNamesSortedRegex; 432 | } 433 | 434 | $names = array_keys($this->getRulesPossibleVarNames()); 435 | sort($names); 436 | usort($names, function ($a, $b) { 437 | $al = mb_strlen($a); 438 | $bl = mb_strlen($b); 439 | 440 | if ($al === $bl) { 441 | return 0; 442 | } 443 | 444 | return $al > $bl ? -1 : 1; 445 | }); 446 | 447 | array_walk($names, function (&$name) { 448 | $name = "_({$name})"; 449 | }); 450 | $regex = '/(?:' . implode('|', $names) . ')$/'; 451 | 452 | return $this->varRuleNamesSortedRegex = $regex; 453 | } 454 | 455 | /** 456 | * Get possible variable names for rules. 457 | * 458 | * @return array 459 | */ 460 | protected function getRulesPossibleVarNames(): array 461 | { 462 | if ($this->varRuleNames !== null) { 463 | return $this->varRuleNames; 464 | } 465 | $names = []; 466 | foreach ($this->varRules as $ruleName => $data) { 467 | $dataShort = ['rule' => $ruleName, 'type' => $data['type']]; 468 | $names[$ruleName] = $dataShort; 469 | if (empty($data['names'])) { 470 | continue; 471 | } 472 | foreach ($data['names'] as $name) { 473 | // Throw an error if duplicates occur 474 | if (isset($names[$name]) && $names[$name]['type'] !== $dataShort['type']) { 475 | throw new \RuntimeException(sprintf("Duplicate name '%s' in variable rules.", $name)); 476 | } 477 | $names[$name] = $dataShort; 478 | } 479 | } 480 | 481 | return $this->varRuleNames = $names; 482 | } 483 | } 484 | -------------------------------------------------------------------------------- /src/Yaml/Variable.php: -------------------------------------------------------------------------------- 1 | configureSelf($config); 82 | } 83 | 84 | /** 85 | * Get object array representation 86 | * @return array 87 | */ 88 | public function toArray(): array 89 | { 90 | $result = []; 91 | $this->fillMissingProperties(); 92 | foreach ($this->getFillable() as $paramName) { 93 | $value = $this->{$paramName}; 94 | if ($value === null && ! in_array($paramName, [static::KEY_TYPE, static::KEY_PROPERTIES], true)) { 95 | continue; 96 | } 97 | $result[$paramName] = $value; 98 | } 99 | 100 | return $result; 101 | } 102 | 103 | /** 104 | * Describe variable 105 | * 106 | * @param bool $useRef 107 | * @return array 108 | */ 109 | public function describe(bool $useRef = true): array 110 | { 111 | $this->fillMissingProperties(); 112 | $typeSwagger = $this->getSwType(); 113 | $result = [ 114 | 'type' => $typeSwagger, 115 | ]; 116 | if (isset($this->description)) { 117 | $result['description'] = trim($this->description); 118 | } 119 | // Set example if it was provided and not empty for array and object type 120 | if ( 121 | $this->example !== null 122 | && ( 123 | ! in_array($typeSwagger, [static::SW_TYPE_OBJECT, static::SW_TYPE_ARRAY], true) 124 | || ! empty($this->example) 125 | ) 126 | ) { 127 | $result['example'] = $this->example; 128 | } 129 | $res = []; 130 | switch ($typeSwagger) { 131 | case static::SW_TYPE_OBJECT: 132 | $className = $this->describer()->normalizeType($this->type); 133 | $classNameExists = $this->describer()->isTypeClassName($className); 134 | if ($useRef && $classNameExists && ($ref = static::getCollectedClassReference($className, $this->with, $this->except, $this->only)) !== null) { 135 | return $ref; 136 | } 137 | $res = ['type' => static::SW_TYPE_OBJECT, 'properties' => []]; 138 | // If class does not exist then $objCacheKey will be NULL 139 | if ($classNameExists) { 140 | $properties = []; 141 | $propsIgnored = $this->getClassIgnoredProperties($className); 142 | $symlinkClasses = [$className => ['merge' => true, 'ignore' => $propsIgnored]]; 143 | $symlinkClass = $className; 144 | /** @var $symlink \OA\Symlink|null */ 145 | while (($symlink = $this->classAnnotation($symlinkClass, \OA\Symlink::class)) !== null && ! isset($symlinkClasses[$symlink->class])) { 146 | $symlinkClasses[$symlinkClass]['merge'] = $symlink->merge; 147 | /** @noinspection SlowArrayOperationsInLoopInspection */ 148 | $propsIgnored = array_merge($propsIgnored, $this->getClassIgnoredProperties($symlink->class)); 149 | $symlinkClasses[$symlink->class] = ['merge' => true, 'ignore' => $propsIgnored]; 150 | $symlinkClass = $symlink->class; 151 | } 152 | $symlinkClasses = array_filter($symlinkClasses, fn ($r) => ! empty($r['merge'])); 153 | foreach ($symlinkClasses as $classNameToParse => $row) { 154 | $propertiesCurrent = $this->getDescriptionByPHPDocTypeClass($classNameToParse, $this->with ?? []); 155 | if (! empty($row['ignore'])) { 156 | $propertiesCurrent = array_diff_key($propertiesCurrent, $row['ignore']); 157 | } 158 | $properties[] = $propertiesCurrent; 159 | } 160 | // Write properties from class and symlinks 161 | $res['properties'] = ! empty($properties) ? $this->describer()->mergeWithPropertiesRewrite(...$properties) : []; 162 | // Get description from a class PHPDoc directly 163 | $res['description'] = $this->description ?? $this->getDescriptionSummaryByPHPDocTypeClass($className); 164 | } elseif (is_array($this->example) && Arr::isAssoc($this->example)) { 165 | $describedEx = $this->describer()->describe($this->example); // W/o nested examples 166 | $res['properties'] = Arr::get($describedEx, 'properties', []); 167 | // Remove already described example 168 | Arr::forget($result, 'example'); 169 | } 170 | // Merge previously set properties 171 | if (is_array($this->properties) && ! empty($this->properties)) { 172 | $res['properties'] = $this->describer()->merge($res['properties'], $this->properties); 173 | } 174 | $res['properties'] = ! empty($this->except) ? Arr::except($res['properties'], $this->except) : $res['properties']; 175 | // Write `$ref` for the class 176 | if ($useRef && $classNameExists) { 177 | return static::setCollectedClassReference( 178 | $className, $this->describer()->merge($res, $result), $this->with, $this->except, $this->only 179 | ); 180 | } 181 | break; 182 | case static::SW_TYPE_ARRAY: 183 | if ($this->describer()->isTypeArray($this->type)) { 184 | $simpleType = $this->describer()->normalizeType($this->type, true); 185 | $item = $this->describer()->isBasicType($simpleType) 186 | ? ['type' => $simpleType] 187 | : (new static(['type' => $simpleType]))->describe(); 188 | } else { 189 | $thatItems = $this->items ?? 'string'; 190 | $item = is_array($thatItems) ? $thatItems : ['type' => $thatItems]; 191 | } 192 | $res = [ 193 | 'items' => $item, 194 | ]; 195 | break; 196 | } 197 | 198 | return $this->describer()->merge($res, $result); 199 | } 200 | 201 | /** 202 | * Get ignored properties. 203 | * 204 | * @param string $className 205 | * @return string[] 206 | */ 207 | protected function getClassIgnoredProperties(string $className): array 208 | { 209 | $annotations = $this->classAnnotations($className, \OA\PropertyIgnore::class); 210 | 211 | return Arr::pluck($annotations, 'name', 'name'); 212 | } 213 | 214 | /** 215 | * Get fillable properties 216 | * @return array 217 | */ 218 | public function getFillable(): array 219 | { 220 | return $this->fillable; 221 | } 222 | 223 | /** 224 | * Describe self as a PHP class. 225 | * 226 | * @return array 227 | * @throws \Throwable 228 | */ 229 | protected function describeAsClass(): array 230 | { 231 | $className = $this->describer()->normalizeType($this->type); 232 | $result = ['type' => static::SW_TYPE_OBJECT, 'properties' => []]; 233 | if (class_exists($className)) { 234 | $result['properties'] = $this->getDescriptionByPHPDocTypeClass($className, $this->with ?? []); 235 | $result['properties'] = ! empty($this->except) ? Arr::except($result['properties'], $this->except) : $result['properties']; 236 | } 237 | 238 | return $result; 239 | } 240 | 241 | /** 242 | * Get swagger type. 243 | * 244 | * @return string|null 245 | */ 246 | protected function getSwType(): ?string 247 | { 248 | if ($this->swaggerType === null) { 249 | $phpType = $this->type !== null 250 | ? $this->describer()->normalizeType($this->type) 251 | : $this->getPHPDocType($this->example); 252 | $simplifiedType = $this->describer()->isTypeClassName($phpType) ? $this->describer()->simplifyClassName($phpType) : $phpType; 253 | if ($phpType === 'array' && is_array($this->example) && Arr::isAssoc($this->example)) { 254 | $swType = static::SW_TYPE_OBJECT; 255 | } elseif ($this->describer()->isTypeArray($phpType)) { 256 | $swType = static::SW_TYPE_ARRAY; 257 | } elseif ($this->describer()->isTypeClassName($phpType)) { 258 | $swType = $simplifiedType === $phpType ? static::SW_TYPE_OBJECT : $simplifiedType; 259 | } else { 260 | $swType = $this->describer()->swaggerType($phpType); 261 | } 262 | 263 | return $this->swaggerType = $swType; 264 | } 265 | 266 | return $this->swaggerType; 267 | } 268 | 269 | /** 270 | * Fill missing properties. 271 | */ 272 | protected function fillMissingProperties(): void 273 | { 274 | $type = $this->type !== null && $this->describer()->isTypeClassName($this->type) ? $this->describer()->simplifyClassName($this->type) : $this->type; 275 | if ($this->type === null && $this->example !== null) { 276 | $this->type = $this->getPHPDocType($this->example); 277 | } elseif ($this->type !== null && $this->example === null && $this->describer()->isBasicType($type)) { 278 | $this->example = $this->getExampleByPHPDocType($this->type); 279 | } 280 | } 281 | 282 | /** 283 | * Get PHPDoc type of value. 284 | * 285 | * @param mixed $value 286 | * @return string|null 287 | */ 288 | protected function getPHPDocType(mixed $value): ?string 289 | { 290 | $baseType = $phpType = $value !== null ? gettype($value) : null; 291 | switch ($baseType) { 292 | case 'array': 293 | if (! Arr::isAssoc($value)) { 294 | $firstValue = reset($value); 295 | if (($mainType = $this->getPHPDocType($firstValue)) !== null) { 296 | $phpType = $mainType . '[]'; 297 | } 298 | } 299 | break; 300 | case 'object': 301 | $phpType = get_class($value); 302 | break; 303 | } 304 | 305 | return $phpType; 306 | } 307 | 308 | /** 309 | * Get example value by PHPDoc type. 310 | * 311 | * @param string $phpType 312 | * @return array|mixed|null 313 | */ 314 | protected function getExampleByPHPDocType(string $phpType): mixed 315 | { 316 | $phpType = $this->describer()->normalizeType($phpType); 317 | $phpTypeSimplified = $this->describer()->simplifyClassName($phpType); 318 | if (($isArrayOf = $this->describer()->isTypeArray($phpType)) !== false) { 319 | $phpType = substr($phpType, 0, -2); 320 | } 321 | if ($phpTypeSimplified === $phpType && $this->describer()->isTypeClassName($phpType)) { 322 | $example = $this->getExampleByPHPDocTypeClass($phpType); 323 | $example = $example ?? []; 324 | } else { 325 | $example = $this->describer()->example($phpType, null, $this->name); 326 | } 327 | if ($isArrayOf) { 328 | $example = [$example]; 329 | } 330 | 331 | return $example; 332 | } 333 | 334 | /** 335 | * Get example by class name. 336 | * 337 | * @param string $className 338 | * @return array|null 339 | */ 340 | protected function getExampleByPHPDocTypeClass(string $className): ?array 341 | { 342 | $parser = new ClassParser($className); 343 | $properties = $parser->properties(true, false); 344 | if (! empty($this->with)) { 345 | $propertiesRead = $parser->propertiesRead($this->with); 346 | $properties = $this->describer()->merge($properties, $propertiesRead); 347 | } 348 | if (empty($properties)) { 349 | return null; 350 | } 351 | $example = []; 352 | foreach ($properties as $name => $row) { 353 | $ex = $this->describer()->example($row['type']); 354 | $example[$name] = $ex; 355 | } 356 | 357 | return $example; 358 | } 359 | 360 | /** 361 | * Get description by class name. 362 | * 363 | * @param string $className 364 | * @param array $with 365 | * @return array 366 | */ 367 | protected function getDescriptionByPHPDocTypeClass(string $className, array $with = []): array 368 | { 369 | $parser = new ClassParser($className); 370 | $properties = $parser->properties(true, false); 371 | $propertiesRead = []; 372 | $propertiesByAnnRead = []; 373 | if (! empty($with)) { 374 | $this->setWithToPropertiesRecursively($properties, $with); 375 | $propertiesRead = $parser->propertiesRead($with, null, false); 376 | $propertiesByAnnRead = $this->getDescriptionByPropertyAnnotations($className, $with, \OA\PropertyRead::class); 377 | } 378 | $propertiesByAnn = $this->getDescriptionByPropertyAnnotations($className); 379 | $properties = $this->describer()->merge($properties, $propertiesRead, $propertiesByAnn, $propertiesByAnnRead); 380 | if (empty($properties)) { 381 | return []; 382 | } 383 | // Ignore properties 384 | $ignored = $this->getIgnoredProperties($className); 385 | if (! empty($ignored)) { 386 | $properties = array_diff_key($properties, $ignored); 387 | } 388 | $described = []; 389 | foreach ($properties as $name => $row) { 390 | if (isset($propertiesByAnn[$name])) { 391 | $described[$name] = Arr::except($row, ['name']); 392 | continue; 393 | } 394 | $row = $this->describer()->merge(['name' => $name], $row); 395 | $nested = static::fromDescription($row); 396 | try { 397 | $described[$name] = $nested->describe(); 398 | } catch (\Throwable $exception) { 399 | dump($row); 400 | throw $exception; 401 | } 402 | } 403 | 404 | return $described; 405 | } 406 | 407 | /** 408 | * Get description from a class PHPDoc. 409 | * 410 | * @param string $className 411 | * @return string|null 412 | */ 413 | protected function getDescriptionSummaryByPHPDocTypeClass(string $className): ?string 414 | { 415 | return (new ClassParser($className))->docSummary(); 416 | } 417 | 418 | /** 419 | * Get properties by annotations 420 | * 421 | * @param string $className 422 | * @param array $only 423 | * @param string $annotationClass 424 | * @return array 425 | */ 426 | protected function getDescriptionByPropertyAnnotations(string $className, array $only = [], string $annotationClass = \OA\Property::class): array 427 | { 428 | /** @var \OA\Property[] $annotations */ 429 | $annotations = $this->classAnnotations($className, $annotationClass); 430 | $result = []; 431 | foreach ($annotations as $annotation) { 432 | $rowData = $annotation->toArray(); 433 | if (! empty($only) && ! in_array($annotation->name, $only, true) && ! $annotation->isNested()) { 434 | continue; 435 | } 436 | // Skip annotations w/o name 437 | if (empty($annotation->name)) { 438 | continue; 439 | } 440 | // Handle nested names (with dots) 441 | if ( 442 | $annotation->isNested() 443 | && ([$nestedPath, $nestedParentPath] = $annotation->getNestedPaths()) 444 | && $nestedParentPath !== null 445 | && ($nestedParentType = Arr::get($result, $nestedParentPath . '.type')) !== null 446 | && in_array($nestedParentType, [static::SW_TYPE_ARRAY, static::SW_TYPE_OBJECT], true) 447 | ) { 448 | unset($rowData['name']); 449 | Arr::set($result, $nestedPath, $rowData); 450 | continue; 451 | } 452 | $result[$annotation->name] = $rowData; 453 | } 454 | 455 | // Cleanup nested annotations 456 | return ! empty($only) ? array_intersect_key($result, array_flip($only)) : $result; 457 | } 458 | 459 | /** 460 | * Get ignored properties. 461 | * 462 | * @param string $className 463 | * @return array 464 | */ 465 | protected function getIgnoredProperties(string $className): array 466 | { 467 | /** @var \OA\PropertyIgnore[] $annotations */ 468 | $annotations = $this->classAnnotations($className, \OA\PropertyIgnore::class); 469 | 470 | return Arr::pluck($annotations, 'name', 'name'); 471 | } 472 | 473 | /** 474 | * Set `with` key to describable properties recursively. 475 | * 476 | * @param array $properties 477 | * @param array $with 478 | */ 479 | protected function setWithToPropertiesRecursively(array &$properties, array $with = []): void 480 | { 481 | foreach ($with as $key) { 482 | if (($pos = strpos($key, '.')) === false) { 483 | continue; 484 | } 485 | if (($keyBase = mb_substr($key, 0, $pos)) && isset($properties, $keyBase)) { 486 | $keyWith = mb_substr($key, $pos + 1); 487 | $properties[$keyBase]['with'] = [$keyWith]; 488 | } 489 | } 490 | } 491 | 492 | /** 493 | * Configure object itself. 494 | * 495 | * @param array $config 496 | */ 497 | protected function configureSelf(array $config = []): void 498 | { 499 | foreach ($config as $key => $value) { 500 | if (property_exists($this, $key)) { 501 | $this->{$key} = $value; 502 | } 503 | } 504 | } 505 | 506 | /** 507 | * Get magic method. 508 | * 509 | * @param string $name 510 | * @return mixed 511 | * @throws \Exception 512 | */ 513 | public function __get(string $name) 514 | { 515 | $method = 'get' . ucfirst($name); 516 | if (method_exists($this, $method)) { 517 | return $this->{$method}(); 518 | } 519 | throw new \RuntimeException("Property {$name} does not exist or is not readable"); 520 | } 521 | 522 | /** 523 | * Set magic method. 524 | * 525 | * @param string $name 526 | * @param mixed $value 527 | * @return mixed 528 | * @throws \Exception 529 | */ 530 | public function __set(string $name, mixed $value) 531 | { 532 | $method = 'set' . ucfirst($name); 533 | if (method_exists($this, $method)) { 534 | return $this->{$method}($value); 535 | } 536 | throw new \RuntimeException("Property {$name} does not exist or is not writable"); 537 | } 538 | 539 | /** 540 | * ISSET magic. 541 | * 542 | * @param string $name 543 | * @return bool 544 | */ 545 | public function __isset(string $name) 546 | { 547 | $method = 'get' . ucfirst($name); 548 | if (method_exists($this, $method)) { 549 | return $this->{$method}() !== null; 550 | } 551 | 552 | return false; 553 | } 554 | 555 | /** 556 | * Create object from array description 557 | * 558 | * @param array $config 559 | * @return Variable 560 | */ 561 | public static function fromDescription(array $config): static 562 | { 563 | return new static($config); 564 | } 565 | 566 | /** 567 | * Create object from example 568 | * 569 | * @param mixed $example 570 | * @param string|null $name 571 | * @param string|null $description 572 | * @return Variable 573 | */ 574 | public static function fromExample(mixed $example, ?string $name = null, ?string $description = null): static 575 | { 576 | $config = [ 577 | 'example' => $example, 578 | 'name' => $name, 579 | 'description' => $description, 580 | ]; 581 | 582 | return new static($config); 583 | } 584 | } 585 | -------------------------------------------------------------------------------- /src/Describer/WithExampleGenerator.php: -------------------------------------------------------------------------------- 1 | phpType($swaggerType) : null); 38 | $swaggerType = $swaggerType ?? ($phpType !== null ? $this->swaggerType($phpType) : null); 39 | // Guess variable type to get from cache 40 | if ($phpType === null && $rule !== null) { 41 | $phpType = $this->getRuleType($rule); 42 | } 43 | // Get from cache 44 | if (($cachedValue = $this->getVarCache($varName, $phpType)) !== null) { 45 | return $cachedValue; 46 | } 47 | // Fill rule and type 48 | $rule = $rule === null || $this->isBasicType($rule) ? $this->getVariableRule($varName, $rule ?? $varName) : $rule; 49 | $phpType = $phpType === null && $rule !== null ? $this->getRuleType($rule) : $phpType; 50 | // Can't guess => leaving 51 | if ($phpType === null) { 52 | return null; 53 | } 54 | $isArray = $this->isTypeArray($phpType); 55 | if ($rule === null || ($example = $this->exampleByRule($rule)) === null) { 56 | $typeClean = $isArray ? substr($phpType, 0, -2) : $phpType; 57 | $example = $this->exampleByType($typeClean, $varName); 58 | } 59 | $example = $this->typeCastExample($example, $phpType); 60 | $example = $isArray ? [$example] : $example; 61 | 62 | return $this->setVarCache($varName, $phpType, $example); 63 | } 64 | 65 | /** 66 | * Simple typecast for generated examples. 67 | * 68 | * @param mixed $example 69 | * @param string|null $phpType 70 | * @return mixed 71 | */ 72 | protected function typeCastExample(mixed $example, ?string $phpType): mixed 73 | { 74 | return match ($phpType) { 75 | 'integer' => (int)$example, 76 | 'float','double' => round((float)$example, 2), 77 | 'string' => (string)$example, 78 | 'boolean', 'bool' => (bool)$example, 79 | default => $example 80 | }; 81 | } 82 | 83 | /** 84 | * Generate example sequence by type 85 | * 86 | * @param string|null $type 87 | * @param int $count 88 | * @return array 89 | */ 90 | protected function generateExampleByTypeSequence(?string $type, int $count = 10): array 91 | { 92 | $type = is_string($type) ? $this->normalizeType($type, true) : null; 93 | $sequence = []; 94 | for ($i = 1; $i <= $count; $i++) { 95 | $elem = $this->exampleByTypeSequential($type, $i); 96 | $sequence[] = $elem; 97 | if ($elem === null) { 98 | break; 99 | } 100 | } 101 | 102 | return $sequence; 103 | } 104 | 105 | /** 106 | * Generate example sequence by rule 107 | * 108 | * @param string $rule 109 | * @param int $count 110 | * @return array 111 | */ 112 | protected function generateExampleByRuleSequence(string $rule, int $count = 10): array 113 | { 114 | $sequence = []; 115 | for ($i = 1; $i <= $count; $i++) { 116 | $elem = $this->exampleByRuleSequential($rule, $i); 117 | $sequence[] = $elem; 118 | if ($elem === null) { 119 | break; 120 | } 121 | } 122 | 123 | return $sequence; 124 | } 125 | 126 | /** 127 | * Get example by given type for sequence 128 | * 129 | * @param string|null $type 130 | * @param int $iteration 131 | * @return mixed 132 | */ 133 | protected function exampleByTypeSequential(?string $type, int $iteration = 1): mixed 134 | { 135 | $dateStr = '2019-01-01 00:00:00'; 136 | switch ($type) { 137 | case 'int': 138 | case 'integer': 139 | return (int)($iteration * 3); 140 | case 'float': 141 | case 'double': 142 | return 1.65 * $iteration; 143 | case 'string': 144 | $strArr = ['string', 'string value', 'string example', 'string data', 'some text']; 145 | return $this->takeFromArray($strArr, $iteration); 146 | case 'bool': 147 | case 'boolean': 148 | return (bool)(($iteration % 2)); 149 | case 'date': 150 | $date = Date::createFromFormat('Y-m-d H:i:s', $dateStr); 151 | $date->addSeconds($iteration * 800636); 152 | return $date->format('Y-m-d'); 153 | case \Illuminate\Support\Carbon::class: 154 | case \Carbon\Carbon::class: 155 | case 'dateTime': 156 | case 'datetime': 157 | $date = Date::createFromFormat('Y-m-d H:i:s', $dateStr); 158 | $date->addSeconds($iteration * 800636); 159 | return $date->format('Y-m-d H:i:s'); 160 | case 'array': 161 | return []; 162 | } 163 | 164 | return null; 165 | } 166 | 167 | /** 168 | * Get example value by validation rule 169 | * 170 | * @param string $rule 171 | * @param int $iteration 172 | * @return mixed 173 | */ 174 | protected function exampleByRuleSequential(string $rule, int $iteration = 1): mixed 175 | { 176 | $dateStr = '2019-01-01 00:00:00'; 177 | switch ($rule) { 178 | case 'phone': 179 | $strArr = ['+380971234567', '+380441234567', '+15411234567', '+4901511234567']; 180 | $example = $this->takeFromArray($strArr, $iteration); 181 | break; 182 | case 'url': 183 | $example = 'http://example.com/url-' . $iteration . '-generated'; 184 | break; 185 | case 'image': 186 | $example = 'https://lorempixel.com/640/480/?' . $iteration * 6842; 187 | break; 188 | case 'email': 189 | $examples = [ 190 | 'nikolaus.jo@haag.net', 'oral46@gleichner.com', 'triston73@gmail.com', 'sedrick.russel@gmail.com', 191 | 'christian64@hotmail.com', 'lwill@baumbach.com', 'stanton.nicolas@schulist.net', 'fisher.benedict@yahoo.com', 192 | 'jaren85@dicki.info', 'thiel.maxwell@ortiz.org', 193 | ]; 194 | $example = $this->takeFromArray($examples, $iteration); 195 | break; 196 | case 'password': 197 | $examples = [ 198 | 'FwsU63aIflde0t2x', 'nYZxNonFfkPwmKRB', 'cLRdk4y0yVdK3QP4', 'LkWwjjdaVK1pGDQf', 'LdKBH0RXvlOOD6kg', 199 | 'YOgNltJWQrWf5AmQ', '8E4BbRtJO4MgRlJP', 'KLuOcU5EYjhLqbHB', 'zr3rZ5GNu1oSaeHQ', 'VpCgk7BS2QP2X5VT', 200 | ]; 201 | $example = $this->takeFromArray($examples, $iteration); 202 | break; 203 | case 'token': 204 | $examples = [ 205 | 'AmUfolr4CMVihtjvHgPcA3IAPGmV9Vknr44sAdAYcmNauKXBssVOjQrZFPlizrKO', 206 | 'KDr5Js6MkvH6XSDdgLF0sQv8RQvDBla3I2YADdtSON3JFK10sYIARZvL7MHv7FG8', 207 | 'aIoZAUw6cfcdOpKhlTFl6btgdbWAazQeujD35aFg6mW2c3RKYgtqtF7i03Vfe4Du', 208 | 'LeJuw7tMULngkbeePqA61Nv88Y4tZWRJkrW7ISkGMRdhYUuh9MhFJVdye81hlQQ1', 209 | 'C6ZJFs4zgJ3ejE13RVJvAwDG1fgv4w4nMoo5MbMDjp4y3JkFibWnrTlmutIQdBtH', 210 | 'tBWDClhFDE0kZJlL0v2iVp1xDJGCakrc3S7M4Btcc19f7x5SqbzxLRtvPMmdKcgO', 211 | 'ZOyU88s36oiCheUTxWwnDW0D21WkzG5RVl7x47Mo7hFmwJkHI4g9LoVgug6jSdsC', 212 | 'AGoIz32MiUcX7W58zeCThiQgyeMoiDa9As4cJz5lJPNaJyOh3XLKovKS69HJ4y6s', 213 | 'Z63TlMogLQ7ibz92psjTO8KrkVgp9KYuOFldOXcvbv2icpPtaaW08ekGqj0b7O8s', 214 | 'ITbeGihCUzEnolxEZbjSWYYTHxQDrNQIaHFPIdJfT36yZ1KimVxKN9b240NEsNpw', 215 | ]; 216 | $example = $this->takeFromArray($examples, $iteration); 217 | break; 218 | case 'service_name': 219 | $example = $this->takeFromArray(['fb', 'google', 'twitter'], $iteration); 220 | break; 221 | case 'domain_name': 222 | $example = 'example-' . $iteration . '.com'; 223 | break; 224 | case 'alpha': 225 | case 'string': 226 | $example = $this->takeFromArray(['string', 'value', 'str value'], $iteration); 227 | break; 228 | case 'text': 229 | $examples = [ 230 | 'Ut tempora hic iusto assumenda. In aut quae possimus provident.', 231 | 'Culpa eius voluptate quae accusantium aut aut. Et ipsa quia aut sint facilis pariatur.', 232 | 'Et itaque qui omnis vero aut. Ipsa esse quae error sed enim. Est et ad similique.', 233 | 'Id aut et voluptatum odio rerum sint veritatis. Omnis dolores quisquam animi.', 234 | 'Omnis et sed sapiente ab. Consequatur voluptatem occaecati nihil atque et.', 235 | 'Beatae optio aperiam voluptatem dolor facere. Nesciunt cum ullam accusantium enim.', 236 | 'Illo ut eum sint. Possimus est quo vel assumenda. Sequi dolorem minus atque et iusto.', 237 | 'Dolores tempora quasi fugit alias. Soluta expedita autem dolor quasi minus.', 238 | 'Sequi nulla omnis quis atque. Sint et adipisci magni qui. Laboriosam et saepe tempora vel.', 239 | 'Delectus quia illo quia et molestiae. Vitae ex non modi sed iste non velit.', 240 | ]; 241 | $example = $this->takeFromArray($examples, $iteration); 242 | break; 243 | case 'textShort': 244 | $examples = [ 245 | 'Soluta quisquam qui tenetur molestias sequi.', 246 | 'Quia et rerum tenetur.', 247 | 'Ea est labore est sit et id.', 248 | 'Aut qui sed ut reprehenderit beatae est qui.', 249 | 'Ducimus id sed eaque id doloremque.', 250 | 'Odit molestias porro quia natus est quo.', 251 | 'Cupiditate aut aut sunt dolor ab sunt sunt.', 252 | 'Velit ut odio dolorem deleniti quas.', 253 | 'Consectetur at molestias repellendus.', 254 | 'Velit fuga culpa et et consequatur ea maxime.', 255 | ]; 256 | $example = $this->takeFromArray($examples, $iteration); 257 | break; 258 | case 'alpha_num': 259 | $example = $this->takeFromArray(['string35', 'value90', 'str20value'], $iteration); 260 | break; 261 | case 'alpha_dash': 262 | $example = $this->takeFromArray(['string_35', 'value-90', 'str_20-value'], $iteration); 263 | break; 264 | case 'ip': 265 | case 'ipv4': 266 | $examples = [ 267 | '106.198.17.238', '92.249.253.53', '68.8.150.135', '57.37.186.183', '192.89.34.71', 268 | '94.195.220.102', '24.185.102.94', '1.152.115.28', '72.47.37.220', '62.64.250.209', 269 | ]; 270 | $example = $this->takeFromArray($examples, $iteration); 271 | break; 272 | case 'ipv6': 273 | $examples = [ 274 | 'bcd2:21a4:3e52:6427:8b21:c58c:74e8:88a7', 275 | '8b44:561e:9514:e750:95a9:57a:aacd:4a37', 276 | 'cacc:4f6a:76ba:2d45:cf08:401c:b2d3:e48', 277 | '5547:bd8f:39fe:230c:750c:9e6c:b6d2:6440', 278 | '1fbe:2af5:6f3f:1d1c:eaf4:cae1:8ba1:7e23', 279 | '66e6:1e1e:8bc6:fa4f:279a:32ef:8489:4fac', 280 | '8221:32fe:1ed3:d582:879a:55ca:61c4:7516', 281 | 'aa68:41af:f8a6:932f:4e84:bf11:f08a:aead', 282 | '553f:be11:1c25:4da8:88c:d498:83e2:c2b1', 283 | '6cf7:d7d9:11a4:5fd0:7627:a0a:cec4:d5b6', 284 | ]; 285 | $example = $this->takeFromArray($examples, $iteration); 286 | break; 287 | case 'float': 288 | $example = $iteration * 1.65; 289 | break; 290 | case 'date_format': 291 | case 'date': 292 | $date = Date::createFromFormat('Y-m-d H:i:s', $dateStr); 293 | $date->addSeconds($iteration * 800636); 294 | $example = $date->format('Y-m-d'); 295 | break; 296 | case 'date-time': 297 | case 'dateTime': 298 | case 'datetime': 299 | $date = Date::createFromFormat('Y-m-d H:i:s', $dateStr); 300 | $date->addSeconds($iteration * 800636); 301 | $example = $date->format('Y-m-d H:i:s'); 302 | break; 303 | case 'numeric': 304 | case 'integer': 305 | $example = (int)($iteration * 3); 306 | break; 307 | case 'boolean': 308 | $example = $this->takeFromArray([false, true], $iteration); 309 | break; 310 | case 'company_name': 311 | $examples = [ 312 | "Walker Group", "Bogan, Abernathy and Parker", "Gutkowski, Stracke and Treutel", "Green Group", "Ortiz-Schmidt", 313 | "Reilly Inc", "Dach-Donnelly", "Koepp, Raynor and Gerlach", "Padberg PLC", "Graham, Lowe and Harber", 314 | ]; 315 | $example = $this->takeFromArray($examples, $iteration); 316 | break; 317 | case 'first_name': 318 | $examples = [ 319 | 'Myriam', 'Sylvan', 'Eldred', 'Joana', 'Carson', 'Madisyn', 'Trever', 'Scotty', 'Oran', 'Guadalupe', 320 | ]; 321 | $example = $this->takeFromArray($examples, $iteration); 322 | break; 323 | case 'last_name': 324 | $examples = [ 325 | 'Fay', 'Cartwright', 'Hansen', 'Swift', 'Crooks', 'Ortiz', 'Johns', 'Howell', 'Stehr', 'Brown', 326 | ]; 327 | $example = $this->takeFromArray($examples, $iteration); 328 | break; 329 | case 'address': 330 | $examples = [ 331 | "611 Thompson Way Suite 012\nEast Elza, FL 57666-8738", 332 | "30319 Fay Spurs Apt. 662\nMurphystad, LA 07944", 333 | "779 Lamont Landing\nPort Allenburgh, AL 54941-5287", 334 | "8360 Imogene Turnpike Suite 023\nLake Baby, FL 26431", 335 | "60943 Keira Turnpike\nSteuberport, DE 69137-2357", 336 | "77390 Koby Crescent Suite 624\nWintheiserton, AR 67349", 337 | "117 Rice Ramp Suite 251\nSouth Horace, AK 97749-6015", 338 | "1825 Tiara Path\nNew Maia, SC 74108", 339 | "790 Sallie Rest\nMaggioland, CO 32943", 340 | "2523 Bergnaum Ferry Suite 247\nJoaquinberg, MS 54512", 341 | ]; 342 | $example = $this->takeFromArray($examples, $iteration); 343 | break; 344 | case 'currency_code': 345 | $example = $this->takeFromArray(['UAH', 'USD', 'EUR', 'CZK', 'AUD', 'CHF', 'CAD'], $iteration); 346 | break; 347 | case 'sex': 348 | $example = $this->takeFromArray(['male', 'female'], $iteration); 349 | break; 350 | default: 351 | $example = null; 352 | } 353 | 354 | return $example; 355 | } 356 | 357 | /** 358 | * Get example by given type 359 | * 360 | * @param string|null $type 361 | * @param string|null $varName 362 | * @return array|int|string|null 363 | */ 364 | protected function exampleByType(?string $type, ?string $varName = null): mixed 365 | { 366 | $type = is_string($type) ? $this->normalizeType($type, true) : null; 367 | if ($type === 'string' && is_string($varName)) { 368 | return Str::headline($varName); 369 | } 370 | $key = $type; 371 | if (! isset($this->varsSequences[$key])) { 372 | $this->varsSequences[$key] = $this->generateExampleByTypeSequence($type, 10); 373 | } 374 | $example = current($this->varsSequences[$key]); 375 | if (next($this->varsSequences[$key]) === false) { 376 | reset($this->varsSequences[$key]); 377 | } 378 | 379 | return $example; 380 | } 381 | 382 | /** 383 | * Get example value by validation rule 384 | * 385 | * @param string $rule 386 | * @return mixed 387 | */ 388 | protected function exampleByRule(string $rule): mixed 389 | { 390 | $key = '__' . $rule; 391 | if (! isset($this->varsSequences[$key])) { 392 | $this->varsSequences[$key] = $this->generateExampleByRuleSequence($rule, 10); 393 | } 394 | $example = current($this->varsSequences[$key]); 395 | if (next($this->varsSequences[$key]) === false) { 396 | reset($this->varsSequences[$key]); 397 | } 398 | 399 | return $example; 400 | } 401 | 402 | /** 403 | * Get variable value from cache 404 | * 405 | * @param string $name 406 | * @param string|null $type 407 | * @return mixed|null 408 | * @internal 409 | */ 410 | protected function getVarCache(string $name, ?string $type): mixed 411 | { 412 | if (($key = $this->getVarCacheKey($name, $type)) === null) { 413 | return null; 414 | } 415 | 416 | return Arr::get($this->varsCache, $key); 417 | } 418 | 419 | /** 420 | * Set variable value to cache 421 | * 422 | * @param string $name 423 | * @param string|null $type 424 | * @param mixed $value 425 | * @return mixed|null 426 | * @internal 427 | */ 428 | protected function setVarCache(string $name, ?string $type, mixed $value): mixed 429 | { 430 | if ($value !== null && ($key = $this->getVarCacheKey($name, $type)) !== null) { 431 | Arr::set($this->varsCache, $key, $value); 432 | } 433 | 434 | return $value; 435 | } 436 | 437 | /** 438 | * @param string $type 439 | * @return mixed 440 | * @internal 441 | */ 442 | protected function exampleByTypeInternal(string $type): mixed 443 | { 444 | return match ($type) { 445 | 'int', 'integer' => $this->faker()->numberBetween(1, 99), 446 | 'float', 'double' => $this->faker()->randomFloat(2), 447 | 'string' => Arr::random(['string', 'value', 'str value']), 448 | 'bool', 'boolean' => $this->faker()->boolean(), 449 | 'date' => $this->faker()->dateTimeBetween('-1 month')->format('Y-m-d'), 450 | \Illuminate\Support\Carbon::class, \Carbon\Carbon::class, 'dateTime', 'datetime' 451 | => $this->faker()->dateTimeBetween('-1 month')->format('Y-m-d H:i:s'), 452 | 'array' => [], 453 | default => null, 454 | }; 455 | 456 | } 457 | 458 | /** 459 | * @param string $rule 460 | * @return mixed 461 | * @internal 462 | */ 463 | protected function exampleByRuleInternal(string $rule) 464 | { 465 | switch ($rule) { 466 | case 'phone': 467 | $example = Arr::random(['+380971234567', '+380441234567', '+15411234567', '+4901511234567']); 468 | break; 469 | case 'url': 470 | $example = $this->faker()->url(); 471 | break; 472 | case 'image': 473 | $example = $this->faker()->imageUrl(); 474 | break; 475 | case 'email': 476 | $example = $this->faker()->safeEmail(); 477 | break; 478 | case 'password': 479 | $example = Str::random(16); 480 | break; 481 | case 'token': 482 | $example = Str::random(64); 483 | break; 484 | case 'service_name': 485 | $example = Arr::random(['fb', 'google', 'twitter']); 486 | break; 487 | case 'domain_name': 488 | $example = $this->faker()->domainName(); 489 | break; 490 | case 'alpha': 491 | case 'string': 492 | $example = Arr::random(['string', 'value', 'str value']); 493 | break; 494 | case 'text': 495 | $example = $this->faker()->text(100); 496 | break; 497 | case 'textShort': 498 | $example = $this->faker()->text(50); 499 | break; 500 | case 'alpha_num': 501 | $example = Arr::random(['string35', 'value90', 'str20value']); 502 | break; 503 | case 'alpha_dash': 504 | $example = Arr::random(['string_35', 'value-90', 'str_20-value']); 505 | break; 506 | case 'ip': 507 | case 'ipv4': 508 | $example = $this->faker()->ipv4; 509 | break; 510 | case 'ipv6': 511 | $example = $this->faker()->ipv6; 512 | break; 513 | case 'float': 514 | $example = $this->faker()->randomFloat(2); 515 | break; 516 | case 'date': 517 | $example = $this->faker()->dateTimeBetween('-1 month')->format('Y-m-d'); 518 | break; 519 | case 'date-time': 520 | case 'dateTime': 521 | case 'datetime': 522 | $example = $this->faker()->dateTimeBetween('-1 month')->format('Y-m-d H:i:s'); 523 | break; 524 | case 'numeric': 525 | case 'integer': 526 | $example = $this->faker()->numberBetween(1, 99); 527 | break; 528 | case 'boolean': 529 | $example = $this->faker()->boolean(); 530 | break; 531 | case 'first_name': 532 | $example = $this->faker()->firstName(); 533 | break; 534 | case 'last_name': 535 | $example = $this->faker()->lastName(); 536 | break; 537 | case 'address': 538 | $example = trim($this->faker()->address()); 539 | break; 540 | default: 541 | $example = null; 542 | } 543 | return $example; 544 | } 545 | 546 | /** 547 | * Create variable cache string key 548 | * 549 | * @param string $name 550 | * @param string|null $type 551 | * @return string|null 552 | */ 553 | private function getVarCacheKey(string $name, ?string $type): ?string 554 | { 555 | $suffixes = ['_confirm', '_original', '_example', '_new']; 556 | if ($type === null) { 557 | return null; 558 | } 559 | foreach ($suffixes as $suffix) { 560 | $len = strlen($suffix); 561 | if (substr($name, -$len) === (string) $suffix) { 562 | $name = substr($name, 0, -$len); 563 | break; 564 | } 565 | } 566 | return $name . '|' . $type; 567 | } 568 | 569 | /** 570 | * Take value from array even if it does not exists by given offset 571 | * 572 | * @param array $array 573 | * @param int $number 574 | * @return mixed 575 | */ 576 | private function takeFromArray(array $array, int $number): mixed 577 | { 578 | if (empty($array)) { 579 | return null; 580 | } 581 | if (! isset($array[$number])) { 582 | $number %= count($array); 583 | } 584 | 585 | return $array[$number]; 586 | } 587 | } 588 | --------------------------------------------------------------------------------