├── phpstan-package.neon ├── .git_hooks ├── pre-commit │ ├── 01-lint-php.sh │ └── 02-php-cs-fixer.sh ├── pre-push │ ├── 02-phpstan.sh │ ├── 03-test-code.sh │ └── 01-composer-validate.sh ├── post-merge │ └── 01-install-dependencies.sh ├── post-checkout │ └── 01-install-dependencies.sh └── scripts │ ├── test-code.sh │ ├── phpstan.sh │ ├── install-dependencies.sh │ ├── composer-validate.sh │ ├── lint-php.sh │ └── php-cs-fixer.sh ├── templates ├── PestTest.template ├── ControllerMethod.template ├── PolicyGate.template ├── ControllerExists.template ├── ControllerEmpty.template ├── routes.template ├── Policy.template ├── Enum.template ├── Resource.template └── Request.template ├── tests ├── expects │ ├── LaravelValidationsNonAvailableContentTypeRequest.php │ ├── LaravelValidationsMultipartFormDataRequest.php │ ├── Controllers │ │ ├── LaravelEmpty_1_Controller.expect │ │ ├── LaravelEmpty_2_Controller.expect │ │ ├── LaravelExists_2_Controller.expect │ │ ├── LaravelEmptyController.php │ │ ├── LaravelExists_1_Controller.expect │ │ └── LaravelExistsController.php │ ├── Policies │ │ ├── LaravelWithoutTraitPolicy.php │ │ └── LaravelPolicy.php │ └── LaravelValidationsApplicationJsonRequest.php ├── resources │ ├── common_schemas.yaml │ ├── schemas │ │ ├── test_resource_generation.yaml │ │ └── test_generation_request_validation.yaml │ └── index.yaml ├── TypesMapperTest.php ├── PSR4PathConverterTest.php ├── TestCase.php ├── Pest.php ├── RouteHandleParserTest.php ├── PolicyGenerationTest.php ├── ResourceGenerationTest.php ├── PhpDocGeneratorTest.php ├── LaravelValidationRulesRequestTest.php ├── ClassParserTest.php └── GenerateServerTest.php ├── .github ├── SECURITY.md ├── workflows │ ├── php-cs-fixer.yml │ └── run-tests.yml └── CONTRIBUTING.md ├── src ├── Exceptions │ └── EnumsNamespaceMissingException.php ├── Enums │ ├── OpenApi3ContentTypeEnum.php │ ├── LaravelValidationRuleEnum.php │ ├── OpenApi3PropertyTypeEnum.php │ └── OpenApi3PropertyFormatEnum.php ├── DTO │ └── ParsedRouteHandler.php ├── Generators │ ├── GeneratorInterface.php │ ├── PestTestsGenerator.php │ ├── EnumsGenerator.php │ ├── RequestsGenerator.php │ ├── TestsGenerator.php │ ├── BaseGenerator.php │ ├── RoutesGenerator.php │ ├── PoliciesGenerator.php │ ├── ResourcesGenerator.php │ └── ControllersGenerator.php ├── Utils │ ├── TypesMapper.php │ ├── RouteHandlerParser.php │ ├── TemplatesManager.php │ ├── PSR4PathConverter.php │ ├── PhpDocGenerator.php │ └── ClassParser.php ├── Data │ ├── Controllers │ │ └── ControllersStorage.php │ └── OpenApi3 │ │ ├── OpenApi3Schema.php │ │ ├── OpenApi3Object.php │ │ └── OpenApi3ObjectProperty.php ├── helpers.php ├── LaravelOpenApiServerGeneratorServiceProvider.php └── Commands │ └── GenerateServer.php ├── phpunit.xml ├── .gitignore ├── .php-cs-fixer.php ├── composer.json ├── phpstan.neon.dist ├── config └── openapi-server-generator.php ├── LICENSE.md └── README.md /phpstan-package.neon: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.git_hooks/pre-commit/01-lint-php.sh: -------------------------------------------------------------------------------- 1 | ../scripts/lint-php.sh -------------------------------------------------------------------------------- /.git_hooks/pre-push/02-phpstan.sh: -------------------------------------------------------------------------------- 1 | ../scripts/phpstan.sh -------------------------------------------------------------------------------- /.git_hooks/pre-push/03-test-code.sh: -------------------------------------------------------------------------------- 1 | ../scripts/test-code.sh -------------------------------------------------------------------------------- /.git_hooks/pre-commit/02-php-cs-fixer.sh: -------------------------------------------------------------------------------- 1 | ../scripts/php-cs-fixer.sh -------------------------------------------------------------------------------- /.git_hooks/pre-push/01-composer-validate.sh: -------------------------------------------------------------------------------- 1 | ../scripts/composer-validate.sh -------------------------------------------------------------------------------- /.git_hooks/post-merge/01-install-dependencies.sh: -------------------------------------------------------------------------------- 1 | ../scripts/install-dependencies.sh -------------------------------------------------------------------------------- /templates/PestTest.template: -------------------------------------------------------------------------------- 1 | ['file'], 3 | ]; -------------------------------------------------------------------------------- /templates/ControllerMethod.template: -------------------------------------------------------------------------------- 1 | public function {{ method }}({{ params }}): Responsable 2 | { 3 | // 4 | } 5 | -------------------------------------------------------------------------------- /templates/PolicyGate.template: -------------------------------------------------------------------------------- 1 | public function {{ method }}(User $user): Response 2 | { 3 | return Response::allow(); 4 | } -------------------------------------------------------------------------------- /templates/ControllerExists.template: -------------------------------------------------------------------------------- 1 | 'int', 9 | 'boolean' => 'bool', 10 | 'string' => 'string', 11 | 'number' => 'int|float', 12 | 'array' => 'array', 13 | 'object' => 'object', 14 | ]; 15 | 16 | public function openApiToPhp(string $type): string 17 | { 18 | return $this->mappings[$type] ?? 'mixed'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/TypesMapperTest.php: -------------------------------------------------------------------------------- 1 | openApiToPhp($input); 7 | expect($result)->toEqual($expected); 8 | })->with([ 9 | ['integer', 'int'], 10 | ['boolean', 'bool'], 11 | ['string', 'string'], 12 | ['number', 'int|float'], 13 | ['array', 'array'], 14 | ['object', 'object'], 15 | ['foo', 'mixed'], 16 | ]); 17 | -------------------------------------------------------------------------------- /.git_hooks/scripts/install-dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # - 'composer update' if changed composer.json 4 | 5 | ESC_SEQ="\x1b[" 6 | COL_RESET=$ESC_SEQ"39;49;00m" 7 | COL_RED=$ESC_SEQ"0;31m" 8 | COL_GREEN=$ESC_SEQ"0;32m" 9 | COL_YELLOW=$ESC_SEQ"0;33m" 10 | 11 | changed_files="$(git diff-tree -r --name-only --no-commit-id HEAD@{1} HEAD)" 12 | 13 | check_run() { 14 | echo "$changed_files" | grep -q "$1" && echo " * changes detected in $1" && echo " * running $2" && eval "$2" 15 | } 16 | 17 | check_run composer.json "composer update" 18 | exit 0 19 | -------------------------------------------------------------------------------- /.git_hooks/scripts/composer-validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Validate composer.json before commit 4 | 5 | ESC_SEQ="\x1b[" 6 | COL_RESET=$ESC_SEQ"39;49;00m" 7 | COL_RED=$ESC_SEQ"0;31m" 8 | COL_GREEN=$ESC_SEQ"0;32m" 9 | COL_YELLOW=$ESC_SEQ"0;33m" 10 | 11 | echo 12 | printf "$COL_YELLOW%s$COL_RESET\n" "Running pre-push hook: \"composer-validate\"" 13 | 14 | VALID=$(composer validate --strict --no-check-publish --no-check-all | grep "is valid") 15 | 16 | if [ "$VALID" != "" ]; then 17 | echo "Okay" 18 | exit 0 19 | else 20 | printf "$COL_RED%s$COL_RESET\r\n" "Composer validate check failed." 21 | exit 1 22 | fi 23 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php-cs-fixer.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Fix styling 24 | -------------------------------------------------------------------------------- /templates/Resource.template: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/Request.template: -------------------------------------------------------------------------------- 1 | 1 ? $parts[1] : null; 13 | 14 | $fqcn = ltrim($parts[0], '\\'); 15 | $fqcnParts = explode("\\", $fqcn); 16 | 17 | $class = array_pop($fqcnParts); 18 | $namespace = $fqcnParts ? implode("\\", $fqcnParts) : null; 19 | 20 | return new ParsedRouteHandler( 21 | namespace: $namespace, 22 | class: $class, 23 | fqcn: $fqcn, 24 | method: $method, 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Data/Controllers/ControllersStorage.php: -------------------------------------------------------------------------------- 1 | controllers[$serversUrl][$path][$method] = $responseCodes; 17 | } 18 | 19 | public function isExistControllerMethod( 20 | string $serversUrl, 21 | string $path, 22 | string $method, 23 | int $responseCode 24 | ): bool { 25 | $codes = $this->controllers[$serversUrl][$path][$method] ?? []; 26 | 27 | return !in_array($responseCode, $codes); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Utils/TemplatesManager.php: -------------------------------------------------------------------------------- 1 | fallbackPath = $fallbackPath; 16 | 17 | return $this; 18 | } 19 | 20 | public function getTemplate(string $templateName): string 21 | { 22 | return $this->filesystem->get($this->getTemplatePath($templateName)); 23 | } 24 | 25 | public function getTemplatePath(string $templateName): string 26 | { 27 | $customPath = rtrim($this->fallbackPath, '/') . "/" . $templateName; 28 | 29 | return $this->fallbackPath && $this->filesystem->exists($customPath) ? $customPath : __DIR__ . '/../../templates/' . $templateName; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Enums/OpenApi3PropertyTypeEnum.php: -------------------------------------------------------------------------------- 1 | LaravelValidationRuleEnum::INTEGER, 18 | OpenApi3PropertyTypeEnum::STRING => LaravelValidationRuleEnum::STRING, 19 | OpenApi3PropertyTypeEnum::BOOLEAN => LaravelValidationRuleEnum::BOOLEAN, 20 | OpenApi3PropertyTypeEnum::NUMBER => LaravelValidationRuleEnum::NUMERIC, 21 | OpenApi3PropertyTypeEnum::ARRAY => LaravelValidationRuleEnum::ARRAY, 22 | default => throw new \Exception('Can\'t convert to Laravel validation rule.'), 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/PSR4PathConverterTest.php: -------------------------------------------------------------------------------- 1 | "/var/www/acme/app"]); 7 | $converter->namespaceToPath("Foo\\Bar"); 8 | })->throws(InvalidArgumentException::class); 9 | 10 | it('can convert namespace to path', function (string $argument) { 11 | $converter = new PSR4PathConverter(["App\\" => "/var/www/acme/app"]); 12 | $result = $converter->namespaceToPath($argument); 13 | expect($result)->toEqual("/var/www/acme/app/Foo/Bar"); 14 | })->with(["App\\Foo\\Bar", "App\\Foo\\Bar\\"]); 15 | 16 | it('can add mappings on the fly', function (string $argument) { 17 | $converter = new PSR4PathConverter(); 18 | $converter->addMappings(["App\\" => "/var/www/acme/app"]); 19 | $result = $converter->namespaceToPath($argument); 20 | expect($result)->toEqual("/var/www/acme/app/Foo/Bar"); 21 | })->with(["App\\Foo\\Bar", "App\\Foo\\Bar\\"]); 22 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | allOf as $allOfItem) { 18 | do_with_all_of($allOfItem, $fn); 19 | } 20 | } 21 | } 22 | } 23 | 24 | if (!function_exists('console_warning')) { 25 | function console_warning(string $text, ?Throwable $e = null): void 26 | { 27 | $output = resolve(ConsoleOutput::class); 28 | 29 | if ($e) { 30 | $text .= "\r\n{$e->getCode()}: {$e->getMessage()}"; 31 | } 32 | 33 | $output->writeln("$text"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Utils/PSR4PathConverter.php: -------------------------------------------------------------------------------- 1 | $path) { 16 | $this->mappings[$namespace] = $path; 17 | } 18 | 19 | return $this; 20 | } 21 | 22 | public function namespaceToPath(?string $namespace): string 23 | { 24 | if (is_null($namespace)) { 25 | return ''; 26 | } 27 | 28 | foreach ($this->mappings as $mappingNamespace => $mappingPath) { 29 | if (str_starts_with($namespace, $mappingNamespace)) { 30 | $namespaceWithoutBase = substr($namespace, strlen($mappingNamespace)); 31 | 32 | return $mappingPath . '/' . trim(str_replace("\\", '/', $namespaceWithoutBase), '/'); 33 | } 34 | } 35 | 36 | throw new InvalidArgumentException("Namespace $namespace is unknown, supported namespaces must start with one of [" . implode(array_keys($this->mappings)) . "]"); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Utils/PhpDocGenerator.php: -------------------------------------------------------------------------------- 1 | prependSpaces("/**{$eol}", $spaces); 11 | foreach ($this->convertTextToLines($text, $deleteEmptyLines) as $line) { 12 | $result .= $this->prependSpaces(" * {$this->safeLine($line)}{$eol}", $spaces); 13 | } 14 | $result .= $this->prependSpaces(" */", $spaces); 15 | 16 | return $result; 17 | } 18 | 19 | private function prependSpaces(string $result, int $spaces = 0): string 20 | { 21 | return str_repeat(' ', $spaces) . $result; 22 | } 23 | 24 | private function safeLine(string $line): string 25 | { 26 | return str_replace('*/', '', $line); 27 | } 28 | 29 | private function convertTextToLines(string $text, bool $deleteEmptyLines): array 30 | { 31 | $lines = explode("\n", $text); 32 | $trimmedLines = array_map(fn ($line) => trim($line), $lines); 33 | 34 | return $deleteEmptyLines ? array_filter($trimmedLines) : $trimmedLines; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Data/OpenApi3/OpenApi3Schema.php: -------------------------------------------------------------------------------- 1 | object = new OpenApi3Object(); 16 | } 17 | 18 | public function fillFromStdRequestBody(OpenApi3ContentTypeEnum $contentType, stdClass $requestBody): void 19 | { 20 | switch ($contentType) { 21 | case OpenApi3ContentTypeEnum::APPLICATION_JSON: 22 | $schema = $requestBody->content->{OpenApi3ContentTypeEnum::APPLICATION_JSON->value}->schema; 23 | do_with_all_of($schema, function (stdClass $p) { 24 | $this->object->fillFromStdObject($p); 25 | }); 26 | 27 | break; 28 | case OpenApi3ContentTypeEnum::MULTIPART_FROM_DATA: 29 | $this->object->fillFromStdObject( 30 | $requestBody->content 31 | ->{OpenApi3ContentTypeEnum::MULTIPART_FROM_DATA->value} 32 | ->schema 33 | ); 34 | 35 | break; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project # 2 | ######################## 3 | .php_cs.cache 4 | .php-cs-fixer.cache 5 | .huskyrc 6 | clients/* 7 | !clients/.gitkeep 8 | storage/ensi 9 | generated 10 | studio.json 11 | build 12 | /node_modules 13 | /vendor 14 | .phpunit.result.cache 15 | composer.lock 16 | composer.local.json 17 | Homestead.json 18 | Homestead.yaml 19 | npm-debug.log 20 | yarn-error.log 21 | 22 | # IDEs # 23 | ################### 24 | *.sublime-project 25 | *.sublime-workspace 26 | /.idea 27 | /.vscode 28 | *.komodoproject 29 | .vscode 30 | 31 | # Static content # 32 | ################### 33 | *.csv 34 | *.pdf 35 | *.doc 36 | *.docx 37 | *.xls 38 | *.xlsx 39 | *.xml 40 | !phpunit.xml 41 | !psalm.xml 42 | *.yml 43 | *.txt 44 | *.wav 45 | *.mp3 46 | *.avi 47 | 48 | # Compiled source # 49 | ################### 50 | *.com 51 | *.class 52 | *.dll 53 | *.exe 54 | *.o 55 | *.so 56 | *.box 57 | 58 | # Packages # 59 | ############ 60 | # it's better to unpack these files and commit the raw source 61 | # git has its own built in compression methods 62 | *.7z 63 | *.dmg 64 | *.gz 65 | *.tgz 66 | *.iso 67 | *.jar 68 | *.rar 69 | *.tar 70 | *.zip 71 | *.phar 72 | 73 | # OS generated files # 74 | ###################### 75 | .DS_Store 76 | .DS_Store? 77 | .nfs* 78 | ._* 79 | .Spotlight-V100 80 | .Trashes 81 | .vagrant 82 | ehthumbs.db 83 | Thumbs.db 84 | sftp-config.json 85 | auth.json -------------------------------------------------------------------------------- /.git_hooks/scripts/lint-php.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Lint all added php-files via 'php -l' 4 | 5 | ROOT_DIR="$(pwd)/" 6 | LIST=$(git diff-index --cached --name-only --diff-filter=ACMR HEAD) 7 | ERRORS_BUFFER="" 8 | ESC_SEQ="\x1b[" 9 | COL_RESET=$ESC_SEQ"39;49;00m" 10 | COL_RED=$ESC_SEQ"0;31m" 11 | COL_GREEN=$ESC_SEQ"0;32m" 12 | COL_YELLOW=$ESC_SEQ"0;33m" 13 | COL_BLUE=$ESC_SEQ"0;34m" 14 | COL_MAGENTA=$ESC_SEQ"0;35m" 15 | COL_CYAN=$ESC_SEQ"0;36m" 16 | 17 | echo 18 | printf "$COL_YELLOW%s$COL_RESET\n" "Running pre-commit hook: \"php-linter\"" 19 | 20 | for file in $LIST 21 | do 22 | EXTENSION=$(echo "$file" | grep -E ".php$|.module$|.inc$|.install$") 23 | if [ "$EXTENSION" != "" ]; then 24 | ERRORS=$(php -l $ROOT_DIR$file 2>&1 | grep "Parse error") 25 | if [ "$ERRORS" != "" ]; then 26 | if [ "$ERRORS_BUFFER" != "" ]; then 27 | ERRORS_BUFFER="$ERRORS_BUFFER\n$ERRORS" 28 | else 29 | ERRORS_BUFFER="$ERRORS" 30 | fi 31 | echo "Syntax errors found in file: $file " 32 | fi 33 | fi 34 | done 35 | if [ "$ERRORS_BUFFER" != "" ]; then 36 | echo 37 | echo "These errors were found in try-to-commit files: " 38 | echo -e $ERRORS_BUFFER 39 | echo 40 | printf "$COL_RED%s$COL_RESET\r\n\r\n" "Can't commit, fix errors first." 41 | exit 1 42 | else 43 | echo "Okay" 44 | exit 0 45 | fi 46 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | mockClassParserGenerator(); 23 | } 24 | 25 | public function makeFilePath(string $path): string 26 | { 27 | return str_replace('/', DIRECTORY_SEPARATOR, $path); 28 | } 29 | 30 | public function mockClassParserGenerator(): void 31 | { 32 | $parser = $this->mock(ClassParser::class); 33 | 34 | $parser->shouldReceive('parse')->andReturnSelf(); 35 | $parser->shouldReceive('hasMethod')->andReturn(false); 36 | $parser->shouldReceive('getContentWithAdditionalMethods')->andReturnArg(0); 37 | } 38 | 39 | /** 40 | * Откатывает действие метода mockClassParserGenerator 41 | * @return void 42 | */ 43 | protected function forgetMockClassParserGenerator(): void 44 | { 45 | $this->forgetMock(ClassParser::class); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR2' => true, 16 | '@PSR12' => true, 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 19 | 'no_unused_imports' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'concat_space' => ['spacing' => 'one'], 25 | 'blank_line_before_statement' => [ 26 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 27 | ], 28 | 'phpdoc_single_line_var_spacing' => true, 29 | 'phpdoc_var_without_name' => true, 30 | 'class_attributes_separation' => [ 31 | 'elements' => [ 32 | 'method' => 'one', 33 | ], 34 | ], 35 | 'method_argument_space' => [ 36 | 'on_multiline' => 'ensure_fully_multiline', 37 | 'keep_multiple_spaces_after_comma' => true, 38 | ], 39 | 'single_trait_insert_per_statement' => true, 40 | 'no_whitespace_in_blank_line' => true, 41 | 'method_chaining_indentation' => true, 42 | 'single_space_around_construct' => true, 43 | ]) 44 | ->setFinder($finder); 45 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | php: [8.1, 8.2, 8.3, 8.4] 16 | laravel: [9.*, 10.*, 11.*, 12.*] 17 | exclude: 18 | - laravel: 11.* 19 | php: 8.1 20 | - laravel: 12.* 21 | php: 8.1 22 | 23 | name: P${{ matrix.php }} - L${{ matrix.laravel }} 24 | 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: ${{ matrix.php }} 34 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 35 | coverage: none 36 | 37 | - name: Setup problem matchers 38 | run: | 39 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 40 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 41 | - name: Install dependencies 42 | run: | 43 | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 44 | composer update --prefer-stable --prefer-dist --no-interaction 45 | 46 | - name: Composer Validate 47 | run: ./.git_hooks/scripts/composer-validate.sh 48 | 49 | - name: Execute tests 50 | run: composer test-ci 51 | 52 | - name: Execute phpstan 53 | run: ./.git_hooks/scripts/phpstan.sh 54 | -------------------------------------------------------------------------------- /src/LaravelOpenApiServerGeneratorServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 18 | __DIR__ . '/../config/' . self::CONFIG_FILE_NAME, 19 | 'openapi-server-generator' 20 | ); 21 | 22 | $this->app->when(TemplatesManager::class) 23 | ->needs('$fallbackPath') 24 | ->give(config('openapi-server-generator.extra_templates_path', '')); 25 | 26 | $this->app->when(PSR4PathConverter::class) 27 | ->needs('$mappings') 28 | ->give(config('openapi-server-generator.namespaces_to_directories_mapping', [])); 29 | 30 | $this->app->singleton(ControllersStorage::class, function () { 31 | return new ControllersStorage(); 32 | }); 33 | } 34 | 35 | public function boot(): void 36 | { 37 | $this->publishes([ 38 | __DIR__ . '/../config/' . self::CONFIG_FILE_NAME => config_path(self::CONFIG_FILE_NAME), 39 | ]); 40 | 41 | if ($this->app->runningInConsole()) { 42 | $this->commands([ 43 | GenerateServer::class, 44 | ]); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 17 | 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Expectations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | When you're writing tests, you often need to check that values meet certain conditions. The 25 | | "expect()" function gives you access to a set of "expectations" methods that you can use 26 | | to assert different things. Of course, you may extend the Expectation API at any time. 27 | | 28 | */ 29 | 30 | //expect()->extend('toBeOne', function () { 31 | // return $this->toBe(1); 32 | //}); 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Functions 37 | |-------------------------------------------------------------------------- 38 | | 39 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 40 | | project that you don't want to repeat in every file. Here you can also expose helpers as 41 | | global functions to help you to reduce the number of lines of code in your test files. 42 | | 43 | */ 44 | 45 | //function something() 46 | //{ 47 | // // .. 48 | //} 49 | -------------------------------------------------------------------------------- /src/Enums/OpenApi3PropertyFormatEnum.php: -------------------------------------------------------------------------------- 1 | LaravelValidationRuleEnum::DATE, 27 | OpenApi3PropertyFormatEnum::DATE_TIME => LaravelValidationRuleEnum::DATE_TIME, 28 | OpenApi3PropertyFormatEnum::PASSWORD => LaravelValidationRuleEnum::PASSWORD, 29 | OpenApi3PropertyFormatEnum::BINARY => LaravelValidationRuleEnum::FILE, 30 | OpenApi3PropertyFormatEnum::EMAIL => LaravelValidationRuleEnum::EMAIL, 31 | OpenApi3PropertyFormatEnum::IPV4 => LaravelValidationRuleEnum::IPV4, 32 | OpenApi3PropertyFormatEnum::IPV6 => LaravelValidationRuleEnum::IPV6, 33 | OpenApi3PropertyFormatEnum::TIMEZONE => LaravelValidationRuleEnum::TIMEZONE, 34 | OpenApi3PropertyFormatEnum::PHONE => LaravelValidationRuleEnum::PHONE, 35 | OpenApi3PropertyFormatEnum::URL => LaravelValidationRuleEnum::URL, 36 | OpenApi3PropertyFormatEnum::UUID => LaravelValidationRuleEnum::UUID, 37 | default => throw new \Exception('Can\'t convert to Laravel validation rule.'), 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.git_hooks/scripts/php-cs-fixer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check code style via '.php-cs-fixer.php' 4 | 5 | EXECUTABLE_NAME=php-cs-fixer 6 | EXECUTABLE_COMMAND=fix 7 | CONFIG_FILE=.php-cs-fixer.php 8 | CONFIG_FILE_PARAMETER='--config' 9 | ROOT=`pwd` 10 | ESC_SEQ="\x1b[" 11 | COL_RESET=$ESC_SEQ"39;49;00m" 12 | COL_RED=$ESC_SEQ"0;31m" 13 | COL_GREEN=$ESC_SEQ"0;32m" 14 | COL_YELLOW=$ESC_SEQ"0;33m" 15 | COL_BLUE=$ESC_SEQ"0;34m" 16 | COL_MAGENTA=$ESC_SEQ"0;35m" 17 | COL_CYAN=$ESC_SEQ"0;36m" 18 | 19 | echo "" 20 | printf "$COL_YELLOW%s$COL_RESET\n" "Running pre-commit hook: \"php-cs-fixer\"" 21 | 22 | # possible locations 23 | locations=( 24 | $ROOT/bin/$EXECUTABLE_NAME 25 | $ROOT/vendor/bin/$EXECUTABLE_NAME 26 | ) 27 | 28 | for location in ${locations[*]} 29 | do 30 | if [[ -x $location ]]; then 31 | EXECUTABLE=$location 32 | break 33 | fi 34 | done 35 | 36 | if [[ ! -x $EXECUTABLE ]]; then 37 | echo "executable $EXECUTABLE_NAME not found, exiting..." 38 | echo "if you're sure this is incorrect, make sure they're executable (chmod +x)" 39 | exit 40 | fi 41 | 42 | echo "using \"$EXECUTABLE_NAME\" located at $EXECUTABLE" 43 | $EXECUTABLE --version 44 | 45 | if [[ -f $ROOT/$CONFIG_FILE ]]; then 46 | CONFIG=$ROOT/$CONFIG_FILE 47 | echo "config file located at $CONFIG loaded" 48 | fi 49 | 50 | FILES=`git status --porcelain | grep -e '^[AM]\(.*\).php$' | cut -c 3- | sed -e "s/_ide_helper.php$//" | sed -e "s/_ide_helper_models.php$//" | sed -e "s/.phpstorm.meta.php$//" | tr '\n' ' ' | sed 's/ *$//g'` 51 | if [ -z "$FILES" ]; then 52 | echo "No php files found to fix." 53 | else 54 | echo "Fixing files ${FILES} in project"; 55 | if [[ -f $CONFIG ]]; then 56 | $EXECUTABLE $EXECUTABLE_COMMAND $CONFIG_FILE_PARAMETER=$CONFIG ${FILES}; 57 | else 58 | $EXECUTABLE $EXECUTABLE_COMMAND ${FILES}; 59 | fi 60 | git add ${FILES} 61 | fi 62 | -------------------------------------------------------------------------------- /tests/RouteHandleParserTest.php: -------------------------------------------------------------------------------- 1 | parse("App\\Http\\Controllers\\CreateUser"); 8 | expect($result)->toEqual(new ParsedRouteHandler( 9 | namespace: "App\Http\Controllers", 10 | class: "CreateUser", 11 | fqcn: "App\Http\Controllers\CreateUser", 12 | method: null, 13 | )); 14 | }); 15 | 16 | 17 | it('can parse handler with leading slash', function () { 18 | $result = (new RouteHandlerParser())->parse("\\App\\Http\\Controllers\\CreateUser"); 19 | expect($result)->toEqual(new ParsedRouteHandler( 20 | namespace: "App\Http\Controllers", 21 | class: "CreateUser", 22 | fqcn: "App\Http\Controllers\CreateUser", 23 | method: null, 24 | )); 25 | }); 26 | 27 | it('can parse handler with action', function (string $handler) { 28 | $result = (new RouteHandlerParser())->parse($handler); 29 | expect($result)->toEqual(new ParsedRouteHandler( 30 | namespace: "App\Http\Controllers", 31 | class: "UsersController", 32 | fqcn: "App\Http\Controllers\UsersController", 33 | method: "store", 34 | )); 35 | })->with([ 36 | "App\\Http\\Controllers\\UsersController@store", 37 | "App\\Http\\Controllers\\UsersController::store", 38 | "\\App\\Http\\Controllers\\UsersController@store", 39 | "\\App\\Http\\Controllers\\UsersController::store", 40 | ]); 41 | 42 | it('can parse handler without namespace', function () { 43 | $result = (new RouteHandlerParser())->parse("CreateUser@foo"); 44 | expect($result)->toEqual(new ParsedRouteHandler( 45 | namespace: null, 46 | class: "CreateUser", 47 | fqcn: "CreateUser", 48 | method: "foo", 49 | )); 50 | }); 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ensi/laravel-openapi-server-generator", 3 | "description": "laravel openapi server generator", 4 | "type": "library", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.1", 8 | "devizzent/cebe-php-openapi": "^1.0", 9 | "laravel/framework": "^9.0 || ^10.0 || ^11.0 || ^12.0" 10 | }, 11 | "require-dev": { 12 | "friendsofphp/php-cs-fixer": "^3.2", 13 | "pestphp/pest": "^1.22 || ^2.0 || ^3.0", 14 | "pestphp/pest-plugin-laravel": "^1.1 || ^2.0 || ^3.0", 15 | "phpstan/extension-installer": "^1.3", 16 | "phpstan/phpstan": "^1.11", 17 | "spaze/phpstan-disallowed-calls": "^2.15", 18 | "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Ensi\\LaravelOpenApiServerGenerator\\": "src/" 23 | }, 24 | "files": [ 25 | "src/helpers.php" 26 | ] 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Ensi\\LaravelOpenApiServerGenerator\\Tests\\": "tests" 31 | } 32 | }, 33 | "scripts": { 34 | "cs": "php-cs-fixer fix --config .php-cs-fixer.php", 35 | "phpstan": "phpstan analyse", 36 | "test": "./vendor/bin/pest --parallel --no-coverage", 37 | "test-ci": "./vendor/bin/pest --no-coverage", 38 | "test-coverage": "XDEBUG_MODE=coverage ./vendor/bin/pest --parallel --coverage", 39 | "test-mutate": "XDEBUG_MODE=coverage ./vendor/bin/pest --mutate --everything --parallel --covered-only" 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "Ensi\\LaravelOpenApiServerGenerator\\LaravelOpenApiServerGeneratorServiceProvider" 45 | ] 46 | } 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "pestphp/pest-plugin": true, 52 | "phpstan/extension-installer": true 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/PolicyGenerationTest.php: -------------------------------------------------------------------------------- 1 | makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 17 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 18 | 19 | $filesystem = $this->mock(Filesystem::class); 20 | $filesystem->shouldReceive('exists')->andReturn(false); 21 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 22 | return (bool)strstr($path, '.template'); 23 | })->andReturnUsing(function ($path) { 24 | return file_get_contents($path); 25 | }); 26 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 27 | 28 | $policies = []; 29 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$policies) { 30 | if (str_contains($path, 'Policy.php')) { 31 | $policies[pathinfo($path, PATHINFO_BASENAME)] = $content; 32 | } 33 | 34 | return true; 35 | }); 36 | 37 | artisan(GenerateServer::class); 38 | 39 | foreach ($policies as $key => $content) { 40 | $methods = []; 41 | preg_match_all('~public function (.*)\(~', $content, $methods); 42 | $policies[$key] = $methods[1]; 43 | } 44 | 45 | assertEqualsCanonicalizing(['methodFoo', 'methodBar'], $policies['PoliciesControllerPolicy.php']); 46 | assertNotEqualsCanonicalizing(['methodWithoutForbiddenResponse'], $policies['PoliciesControllerPolicy.php']); 47 | }); 48 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon 3 | - ./phpstan-package.neon 4 | 5 | parameters: 6 | paths: 7 | - src 8 | 9 | scanFiles: 10 | 11 | # Pest handles loading custom helpers only when running tests 12 | # @see https://pestphp.com/docs/helpers#usage 13 | - tests/Pest.php 14 | 15 | # The level 9 is the highest level 16 | level: 5 17 | 18 | ignoreErrors: 19 | - '#PHPDoc tag @var#' 20 | 21 | - '#Unsafe usage of new static\(\)\.#' 22 | 23 | # Pest implicitly binds $this to the current test case 24 | # @see https://pestphp.com/docs/underlying-test-case 25 | - 26 | message: '#^Undefined variable: \$this$#' 27 | path: '*Test.php' 28 | 29 | # Pest custom expectations are dynamic and not conducive static analysis 30 | # @see https://pestphp.com/docs/expectations#custom-expectations 31 | - 32 | message: '#Call to an undefined method Pest\\Expectation|Pest\\Support\\Extendable::#' 33 | path: '*Test.php' 34 | 35 | # Pest allow pass any array for TestCall::with 36 | - 37 | message: '#Parameter \#\d ...\$data of method Pest\\PendingCalls\\TestCall::with(.*) array(.*)given#' 38 | path: '*Test.php' 39 | 40 | # Ignore custom method for Faker\Generator 41 | - 42 | message: '#Call to an undefined method Faker\\Generator|Ensi\\LaravelTestFactories\\FakerProvider::#' 43 | path: '*Factory.php' 44 | 45 | # Ignore transfer of UploadedFile in auto-generated lib 46 | - 47 | message: '#expects SplFileObject\|null, Illuminate\\Http\\UploadedFile given.#' 48 | path: '*Action.php' 49 | 50 | excludePaths: 51 | - ./*/*/FileToBeExcluded.php 52 | 53 | disallowedFunctionCalls: 54 | - 55 | function: 'dd()' 56 | message: 'use some logger instead' 57 | - 58 | function: 'dump()' 59 | message: 'use some logger instead' 60 | 61 | reportUnmatchedIgnoredErrors: false 62 | -------------------------------------------------------------------------------- /tests/ResourceGenerationTest.php: -------------------------------------------------------------------------------- 1 | makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 16 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 17 | 18 | $filesystem = $this->mock(Filesystem::class); 19 | $filesystem->shouldReceive('exists')->andReturn(false); 20 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 21 | return (bool)strstr($path, '.template'); 22 | })->andReturnUsing(function ($path) { 23 | return file_get_contents($path); 24 | }); 25 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 26 | $resources = []; 27 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$resources) { 28 | if (str_contains($path, 'Resource.php')) { 29 | $resources[pathinfo($path, PATHINFO_BASENAME)] = $content; 30 | } 31 | 32 | return true; 33 | }); 34 | 35 | 36 | artisan(GenerateServer::class); 37 | 38 | // С помощью регулярки достаем все выражения в кавычках 39 | foreach ($resources as $key => $content) { 40 | $matches = []; 41 | preg_match_all('~[\'](.*)[\']~', $content, $matches); 42 | $resources[$key] = $matches[1]; 43 | } 44 | 45 | assertEqualsCanonicalizing(['foo', 'bar'], $resources['ResourcesResource.php']); 46 | assertEqualsCanonicalizing(['foo', 'bar'], $resources['ResourcesDataDataResource.php']); 47 | assertEqualsCanonicalizing(['foo', 'bar'], $resources['ResourcesDataWithNameResource.php']); 48 | assertEqualsCanonicalizing(['data'], $resources['ResourceRootResource.php']); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/PhpDocGeneratorTest.php: -------------------------------------------------------------------------------- 1 | fromText("some text"); 8 | 9 | $expected = <<<"EOD" 10 | /** 11 | * some text 12 | */ 13 | EOD; 14 | expect($result)->toEqual($expected); 15 | }); 16 | 17 | it('can generate phpdoc from with prepending spaces', function () { 18 | $generator = new PhpDocGenerator(); 19 | $result = $generator->fromText("some text", 4); 20 | 21 | $expected = <<<"EOD" 22 | /** 23 | * some text 24 | */ 25 | EOD; 26 | expect($result)->toEqual($expected); 27 | }); 28 | 29 | it('can generate phpdoc from multiline string', function () { 30 | $generator = new PhpDocGenerator(); 31 | $multilineString = <<<'EOT' 32 | This is a test comment 33 | It is also multiline 34 | Wow 35 | EOT; 36 | $result = $generator->fromText($multilineString, 4); 37 | 38 | $expected = <<<"EOD" 39 | /** 40 | * This is a test comment 41 | * It is also multiline 42 | * Wow 43 | */ 44 | EOD; 45 | expect($result)->toEqual($expected); 46 | }); 47 | 48 | it('erases phpdoc end', function () { 49 | $generator = new PhpDocGenerator(); 50 | $result = $generator->fromText("broken */text"); 51 | 52 | $expected = <<<"EOD" 53 | /** 54 | * broken text 55 | */ 56 | EOD; 57 | expect($result)->toEqual($expected); 58 | }); 59 | 60 | it('trims lines', function () { 61 | $generator = new PhpDocGenerator(); 62 | $result = $generator->fromText(" some text "); 63 | 64 | $expected = <<<"EOD" 65 | /** 66 | * some text 67 | */ 68 | EOD; 69 | expect($result)->toEqual($expected); 70 | }); 71 | 72 | it('deletes empty lines if configured', function () { 73 | $generator = new PhpDocGenerator(); 74 | $multilineString = <<<'EOT' 75 | This is a test comment 76 | 77 | It is also multiline 78 | And with empty line 79 | Wow 80 | EOT; 81 | $result = $generator->fromText($multilineString, deleteEmptyLines: true); 82 | 83 | $expected = <<<"EOD" 84 | /** 85 | * This is a test comment 86 | * It is also multiline 87 | * And with empty line 88 | * Wow 89 | */ 90 | EOD; 91 | expect($result)->toEqual($expected); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/resources/schemas/test_resource_generation.yaml: -------------------------------------------------------------------------------- 1 | ResourceForTestResourceGeneration: 2 | allOf: 3 | - $ref: '#/ResourceReadOnlyProperties' 4 | - $ref: '#/ResourceFillableProperties' 5 | - $ref: '#/ResourceRequired' 6 | 7 | ResourceForTestResourceWithNameGeneration: 8 | x-lg-resource-class-name: ResourcesDataWithNameResource 9 | allOf: 10 | - $ref: '#/ResourceReadOnlyProperties' 11 | - $ref: '#/ResourceFillableProperties' 12 | - $ref: '#/ResourceRequired' 13 | 14 | ResourceReadOnlyProperties: 15 | type: object 16 | properties: 17 | foo: 18 | type: string 19 | 20 | ResourceFillableProperties: 21 | type: object 22 | properties: 23 | bar: 24 | type: string 25 | 26 | ResourceRequired: 27 | type: object 28 | required: 29 | - foo 30 | 31 | ResourceForTestResourceGenerationResponse: 32 | type: object 33 | properties: 34 | data: 35 | $ref: '#/ResourceForTestResourceGeneration' 36 | 37 | ResourceDataDataResponse: 38 | type: object 39 | x-lg-resource-response-key: data.data 40 | x-lg-resource-class-name: ResourcesDataDataResource 41 | properties: 42 | data: 43 | properties: 44 | data: 45 | $ref: '#/ResourceForTestResourceGeneration' 46 | 47 | ResourceWithDirResponse: 48 | type: object 49 | x-lg-resource-response-key: data.data 50 | x-lg-resource-class-name: Foo/WithDirResource 51 | properties: 52 | data: 53 | properties: 54 | data: 55 | $ref: '#/ResourceForTestResourceGeneration' 56 | 57 | ResourceDataWithNameResponse: 58 | type: object 59 | x-lg-resource-response-key: data.test 60 | properties: 61 | data: 62 | properties: 63 | test: 64 | $ref: '#/ResourceForTestResourceWithNameGeneration' 65 | 66 | ResourceRootResponse: 67 | type: object 68 | x-lg-resource-response-key: false 69 | x-lg-resource-class-name: ResourceRootResource 70 | properties: 71 | data: 72 | properties: 73 | data: 74 | $ref: '#/ResourceForTestResourceGeneration' 75 | 76 | GenerateResourceBadResponseKeyResponse: 77 | type: object 78 | x-lg-resource-response-key: data.key 79 | x-lg-resource-class-name: GenerateResourceBadResponseKeyResource 80 | properties: 81 | data: 82 | properties: 83 | data: 84 | $ref: '#/ResourceForTestResourceGeneration' 85 | 86 | GenerateResourceWithoutPropertiesResponse: 87 | type: object 88 | x-lg-resource-response-key: false 89 | x-lg-resource-class-name: GenerateResourceWithoutPropertiesResource 90 | 91 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 44 | 45 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 46 | 47 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 48 | 49 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 50 | 51 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 52 | 53 | **Happy coding**! 54 | -------------------------------------------------------------------------------- /src/Generators/PestTestsGenerator.php: -------------------------------------------------------------------------------- 1 | $httpMethod, 'responseContentType' => $responseContentType]) { 11 | $newImport = "use function Pest\Laravel\\" . $this->getPhpHttpTestMethod($httpMethod, $responseContentType) . ";"; 12 | $importsArray[$newImport] = $newImport; 13 | } 14 | 15 | sort($importsArray); 16 | 17 | return implode("\n", $importsArray); 18 | } 19 | 20 | private function getPhpHttpTestMethod(string $httpMethod, string $responseContentType): string 21 | { 22 | return $responseContentType === 'application/json' 23 | ? $this->getPhpHttpTestMethodJson($httpMethod) 24 | : $this->getPhpHttpTestMethodCommon($httpMethod); 25 | } 26 | 27 | private function getPhpHttpTestMethodJson(string $httpMethod): string 28 | { 29 | return $httpMethod . 'Json'; 30 | } 31 | 32 | private function getPhpHttpTestMethodCommon(string $httpMethod): string 33 | { 34 | return $httpMethod; 35 | } 36 | 37 | protected function convertRoutesToTestsString(array $routes, string $serversUrl, bool $onlyNewMethods = false): string 38 | { 39 | $testsFunctions = $onlyNewMethods ? [] : ["uses()->group('component');"]; 40 | 41 | foreach ($routes as $route) { 42 | foreach ($route['responseCodes'] as $responseCode) { 43 | if ($responseCode < 200 || $responseCode >= 500) { 44 | continue; 45 | } 46 | 47 | $methodExists = $this->controllersStorage->isExistControllerMethod( 48 | serversUrl: $serversUrl, 49 | path: $route['path'], 50 | method: $route['method'], 51 | responseCode: $responseCode, 52 | ); 53 | 54 | if ($onlyNewMethods && $methodExists) { 55 | continue; 56 | } 57 | 58 | $url = $serversUrl . $route['path']; 59 | $testName = strtoupper($route['method']) . ' ' . $url . ' ' . $responseCode; 60 | $phpHttpMethod = $this->getPhpHttpTestMethod($route['method'], $route['responseContentType']); 61 | $testsFunctions[] = <<assertStatus({$responseCode}); 66 | }); 67 | FUNC; 68 | } 69 | } 70 | 71 | return implode("\n", $testsFunctions); 72 | } 73 | 74 | protected function getTemplateName(): string 75 | { 76 | return "PestTest.template"; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /config/openapi-server-generator.php: -------------------------------------------------------------------------------- 1 | [ 21 | public_path('api-docs/v1/index.yaml') => [ 22 | 'params' => [ 23 | 'apiVersion' => 1, 24 | ], 25 | 'controllers' => [], 26 | 'enums' => [ 27 | 'namespace' => "App\\Http\\ApiV1\\OpenApiGenerated\\Enums\\", 28 | ], 29 | 'requests' => [ 30 | 'namespace' => ["Controllers" => "Requests"], 31 | ], 32 | 'routes' => [ 33 | 'namespace' => "App\\Http\\ApiV1\\OpenApiGenerated\\", 34 | ], 35 | 'pest_tests' => [ 36 | 'namespace' => ["Controllers" => "Tests"], 37 | ], 38 | 'resources' => [ 39 | 'response_key' => 'data', 40 | ], 41 | 'policies' => [ 42 | 'namespace' => ["Controllers" => "Policies"], 43 | ], 44 | ], 45 | ], 46 | 47 | /** 48 | * Full list of base namespaces that are supported in `api_docs_mappings`. 49 | */ 50 | 'namespaces_to_directories_mapping' => [ 51 | 'App\\' => app_path(), 52 | ], 53 | 54 | /** 55 | * List of supported entities and their corresponding generators 56 | */ 57 | 'supported_entities' => [ 58 | 'controllers' => ControllersGenerator::class, 59 | 'enums' => EnumsGenerator::class, 60 | 'requests' => RequestsGenerator::class, 61 | 'routes' => RoutesGenerator::class, 62 | 'pest_tests' => PestTestsGenerator::class, 63 | 'resources' => ResourcesGenerator::class, 64 | 'policies' => PoliciesGenerator::class, 65 | ], 66 | 67 | /** 68 | * List of entities generated by default. 69 | */ 70 | 'default_entities_to_generate' => [ 71 | 'controllers', 72 | 'enums', 73 | 'requests', 74 | 'routes', 75 | 'pest_tests', 76 | 'resources', 77 | 'policies', 78 | ], 79 | 80 | 'extra_templates_path' => resource_path('openapi-server-generator/templates'), 81 | ]; 82 | -------------------------------------------------------------------------------- /tests/expects/LaravelValidationsApplicationJsonRequest.php: -------------------------------------------------------------------------------- 1 | return [ 2 | 'field_object_nullable_fillable' => ['nullable'], 3 | 'field_object_nullable_fillable.field' => ['integer'], 4 | 'field_array_nullable_fillable' => ['nullable', 'array'], 5 | 'field_array_nullable_fillable.*.field' => ['integer'], 6 | 'field_enum_nullable_fillable' => ['nullable', new Enum(TestIntegerEnum::class)], 7 | 'field_number_nullable_fillable' => ['nullable', 'numeric'], 8 | 'field_boolean_nullable_fillable' => ['nullable', 'boolean'], 9 | 'field_string_nullable_fillable' => ['nullable', 'string'], 10 | 'field_integer_nullable_fillable' => ['nullable', 'integer'], 11 | 'field_object_required_fillable' => ['required'], 12 | 'field_object_required_fillable.field' => ['integer'], 13 | 'field_array_required_fillable' => ['required', 'array'], 14 | 'field_array_required_fillable.*.field' => ['integer'], 15 | 'field_enum_required_fillable' => ['required', new Enum(TestIntegerEnum::class)], 16 | 'field_number_required_fillable' => ['required', 'numeric'], 17 | 'field_boolean_required_fillable' => ['required', 'boolean'], 18 | 'field_string_required_fillable' => ['required', 'string'], 19 | 'field_integer_required_fillable' => ['required', 'integer'], 20 | 'field_object_fillable.field' => ['integer'], 21 | 'field_array_fillable' => ['array'], 22 | 'field_array_fillable.*.field' => ['integer'], 23 | 'field_enum_fillable' => [new Enum(TestIntegerEnum::class)], 24 | 'field_number_fillable' => ['numeric'], 25 | 'field_boolean_fillable' => ['boolean'], 26 | 'field_string_uuid_fillable' => ['uuid'], 27 | 'field_string_url_fillable' => ['url'], 28 | 'field_string_phone_fillable' => ['regex:/^\+7\d{10}$/'], 29 | 'field_string_timezone_fillable' => ['timezone'], 30 | 'field_string_ipv6_fillable' => ['ipv6'], 31 | 'field_string_ipv4_fillable' => ['ipv4'], 32 | 'field_string_email_fillable' => ['email'], 33 | 'field_string_binary_fillable' => ['file'], 34 | 'field_string_byte_fillable' => ['string'], 35 | 'field_string_password_fillable' => ['password'], 36 | 'field_string_date_fillable' => ['date'], 37 | 'field_string_fillable' => ['string'], 38 | 'field_integer_double_fillable' => ['integer'], 39 | 'field_integer_fillable' => ['integer'], 40 | 'field_object_readonly.field' => ['integer'], 41 | 'field_allOf_readonly' => [new Enum(TestStringEnum::class)], 42 | 'field_array_allOf_readonly' => ['array'], 43 | 'field_array_allOf_readonly.*' => [new Enum(TestStringEnum::class)], 44 | 'field_array_readonly' => ['array'], 45 | 'field_array_readonly.*.field' => ['integer'], 46 | 'field_enum_readonly' => [new Enum(TestIntegerEnum::class)], 47 | 'field_number_readonly' => ['numeric'], 48 | 'field_boolean_readonly' => ['boolean'], 49 | 'field_integer_readonly' => ['integer'], 50 | ]; -------------------------------------------------------------------------------- /src/Generators/EnumsGenerator.php: -------------------------------------------------------------------------------- 1 | options['enums']['namespace'] ?? null; 15 | if (!is_string($namespaceData)) { 16 | throw new InvalidArgumentException("EnumsGenerator must be configured with string as 'namespace'"); 17 | } 18 | 19 | $namespace = rtrim($namespaceData, "\\"); 20 | $toDir = $this->psr4PathConverter->namespaceToPath($namespace); 21 | 22 | $this->prepareDestinationDir($toDir); 23 | 24 | $openApiData = $specObject->getSerializableData(); 25 | $enums = $this->extractEnums($openApiData); 26 | 27 | $template = $this->templatesManager->getTemplate('Enum.template'); 28 | foreach ($enums as $enumName => $schema) { 29 | $enumType = $this->getEnumType($schema, $enumName); 30 | $this->filesystem->put( 31 | rtrim($toDir, '/') . "/{$enumName}.php", 32 | $this->replacePlaceholders($template, [ 33 | '{{ namespace }}' => $namespace, 34 | '{{ enumName }}' => $enumName, 35 | '{{ cases }}' => $this->convertEnumSchemaToCases($schema), 36 | '{{ enumType }}' => $enumType, 37 | '{{ enumPhpDoc }}' => $this->convertEnumSchemaToPhpDoc($schema), 38 | ]) 39 | ); 40 | } 41 | } 42 | 43 | private function extractEnums(stdClass $openApiData): array 44 | { 45 | $schemas = (array) $openApiData->components?->schemas; 46 | 47 | return array_filter($schemas, fn ($schema) => !empty($schema->enum)); 48 | } 49 | 50 | private function getEnumType(stdClass $schema, string $enumName): string 51 | { 52 | return match ($schema->type) { 53 | "integer" => "int", 54 | "string" => "string", 55 | default => throw new LogicException("Enum {$enumName} has invalid type '{$schema->type}'. Supported types are: ['integer', 'string']"), 56 | }; 57 | } 58 | 59 | private function convertEnumSchemaToCases(stdClass $schema): string 60 | { 61 | $result = ''; 62 | foreach ($schema->enum as $i => $enum) { 63 | $varName = $schema->{'x-enum-varnames'}[$i] ?? null; 64 | if ($varName === null) { 65 | throw new LogicException("x-enum-varnames for enum \"{$enum}\" is not set"); 66 | } 67 | $description = $schema->{'x-enum-descriptions'}[$i] ?? null; 68 | if ($description) { 69 | $result .= " /** {$description} */\n"; 70 | } 71 | $value = var_export($enum, true); 72 | $result .= " case {$varName} = {$value};\n"; 73 | } 74 | 75 | return rtrim($result, "\n"); 76 | } 77 | 78 | private function convertEnumSchemaToPhpDoc(stdClass $schema): string 79 | { 80 | return $schema->description 81 | ? "\n" . $this->phpDocGenerator->fromText(text: $schema->description, deleteEmptyLines: true) 82 | : "\n"; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Data/OpenApi3/OpenApi3Object.php: -------------------------------------------------------------------------------- 1 | properties = collect(); 18 | } 19 | 20 | public function fillFromStdObject(stdClass $object): void 21 | { 22 | if (std_object_has($object, 'properties')) { 23 | foreach (get_object_vars($object->properties) as $propertyName => $property) { 24 | /** @var OpenApi3ObjectProperty|null $objectProperty */ 25 | $objectProperty = $this->properties->get($propertyName); 26 | if (!$objectProperty) { 27 | do_with_all_of($property, function (stdClass $p) use (&$objectProperty, $propertyName) { 28 | if (!$objectProperty && std_object_has($p, 'type')) { 29 | $objectProperty = new OpenApi3ObjectProperty(type: $p->type, name: $propertyName); 30 | } 31 | }); 32 | if (!$objectProperty) { 33 | continue; 34 | } 35 | $this->properties->put($propertyName, $objectProperty); 36 | } 37 | do_with_all_of($property, function (stdClass $p) use ($objectProperty, $propertyName) { 38 | $objectProperty->fillFromStdProperty($propertyName, $p); 39 | }); 40 | } 41 | } 42 | if (std_object_has($object, 'required') && is_array($object->required)) { 43 | foreach ($object->required as $requiredProperty) { 44 | $objectProperty = $this->properties->get($requiredProperty); 45 | if (!$objectProperty) { 46 | $objectProperty = new OpenApi3ObjectProperty( 47 | type: OpenApi3PropertyTypeEnum::OBJECT->value, 48 | name: $requiredProperty, 49 | ); 50 | $this->properties->put($requiredProperty, $objectProperty); 51 | } 52 | 53 | $objectProperty->required = true; 54 | } 55 | } 56 | } 57 | 58 | public function toLaravelValidationRules(array $options): array 59 | { 60 | $validations = []; 61 | $enums = []; 62 | foreach ($this->properties as $property) { 63 | [$propertyValidations, $propertyEnums] = $property->getLaravelValidationsAndEnums($options); 64 | 65 | $validations = array_merge($propertyValidations, $validations); 66 | $enums = array_merge($propertyEnums, $enums); 67 | } 68 | 69 | if ($validations) { 70 | $validationStrings = []; 71 | foreach ($validations as $propertyName => $validation) { 72 | $validationString = implode(', ', $validation); 73 | $validationStrings[] = "'{$propertyName}' => [{$validationString}],"; 74 | } 75 | $validationsString = implode("\n ", $validationStrings); 76 | } else { 77 | $validationsString = ''; 78 | } 79 | 80 | if ($enums) { 81 | throw_unless(isset($options['enums']['namespace']), EnumsNamespaceMissingException::class); 82 | 83 | $enumStrings = ['use Illuminate\Validation\Rules\Enum;']; 84 | foreach ($enums as $enumClass => $value) { 85 | $enumStrings[] = 'use ' . $options['enums']['namespace'] . "{$enumClass};"; 86 | } 87 | sort($enumStrings); 88 | $enumsString = implode("\n", $enumStrings); 89 | } else { 90 | $enumsString = ''; 91 | } 92 | 93 | return [$validationsString, $enumsString]; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Commands/GenerateServer.php: -------------------------------------------------------------------------------- 1 | config = config('openapi-server-generator', []); 38 | } 39 | 40 | /** 41 | * Execute the console command. 42 | * 43 | * @return int 44 | */ 45 | public function handle() 46 | { 47 | $inputEntities = $this->option('entities') ? explode(',', $this->option('entities')) : []; 48 | $this->enabledEntities = $inputEntities ?: $this->config['default_entities_to_generate']; 49 | 50 | if (!$this->validateEntities()) { 51 | return self::FAILURE; 52 | } 53 | 54 | foreach ($this->config['api_docs_mappings'] as $sourcePath => $optionsPerEntity) { 55 | $this->info("Generating [" . implode(', ', $this->enabledEntities) . "] for specification file \"$sourcePath\""); 56 | if (self::FAILURE === $this->handleMapping($sourcePath, $optionsPerEntity)) { 57 | return self::FAILURE; 58 | } 59 | } 60 | 61 | return self::SUCCESS; 62 | } 63 | 64 | public function handleMapping(string $sourcePath, array $optionsPerEntity) 65 | { 66 | $specObject = $this->parseSpec($sourcePath); 67 | 68 | foreach (static::SUPPORTED_ENTITIES as $entity) { 69 | $generatorClass = $this->config['supported_entities'][$entity] ?? null; 70 | if (!isset($generatorClass)) { 71 | continue; 72 | } 73 | 74 | if (!$this->shouldEntityBeGenerated($entity)) { 75 | continue; 76 | } 77 | 78 | if (!isset($optionsPerEntity[$entity])) { 79 | $this->error("Options for entity \"$entity\" are not set in \"api_docs_mappings\" config for source \"$sourcePath\""); 80 | 81 | return self::FAILURE; 82 | } 83 | 84 | $this->infoIfVerbose("Generating files for entity \"$entity\" using generator \"$generatorClass\""); 85 | 86 | try { 87 | resolve($generatorClass)->setOptions($optionsPerEntity)->generate($specObject); 88 | } catch (EnumsNamespaceMissingException) { 89 | $this->error("Option \"enums_namespace\" for entity \"$entity\" are not set in \"api_docs_mappings\" config for source \"$sourcePath\""); 90 | 91 | return self::FAILURE; 92 | } 93 | } 94 | 95 | return self::SUCCESS; 96 | } 97 | 98 | private function validateEntities(): bool 99 | { 100 | $supportedEntities = array_keys($this->config['supported_entities'] ?? []); 101 | foreach ($this->enabledEntities as $entity) { 102 | if (!in_array($entity, $supportedEntities)) { 103 | $this->error("Invalid entity \"$entity\", supported entities: [" . implode(', ', $supportedEntities) . "]"); 104 | 105 | return false; 106 | } 107 | } 108 | 109 | return true; 110 | } 111 | 112 | private function shouldEntityBeGenerated(string $entity): bool 113 | { 114 | return in_array($entity, $this->enabledEntities); 115 | } 116 | 117 | private function parseSpec(string $sourcePath): SpecObjectInterface 118 | { 119 | return match (substr($sourcePath, -5)) { 120 | '.yaml' => Reader::readFromYamlFile(realpath($sourcePath)), 121 | '.json' => Reader::readFromJsonFile(realpath($sourcePath)), 122 | default => throw new LogicException("You should specify .yaml or .json file as a source. \"$sourcePath\" was given instead"), 123 | }; 124 | } 125 | 126 | protected function infoIfVerbose(string $message): void 127 | { 128 | $this->info($message, 'v'); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Utils/ClassParser.php: -------------------------------------------------------------------------------- 1 | ref = new ReflectionClass($className); 26 | $this->methods = null; 27 | $this->traits = null; 28 | 29 | return $this; 30 | } 31 | 32 | public function getClassName(): string 33 | { 34 | return $this->ref->getName(); 35 | } 36 | 37 | public function isEmpty(): bool 38 | { 39 | return $this->getMethods()->isEmpty(); 40 | } 41 | 42 | public function getMethods(): Collection 43 | { 44 | if (!$this->methods) { 45 | $this->methods = collect($this->ref->getMethods())->keyBy('name'); 46 | } 47 | 48 | return $this->methods; 49 | } 50 | 51 | public function getTraits(): Collection 52 | { 53 | if (!$this->traits) { 54 | $this->traits = collect($this->ref->getTraits())->map(function (ReflectionClass $trait) { 55 | return collect($trait->getMethods())->pluck('name'); 56 | }); 57 | } 58 | 59 | return $this->traits; 60 | } 61 | 62 | public function isTraitMethod(string $methodName): bool 63 | { 64 | $traits = $this->getTraits(); 65 | 66 | return $traits->contains(fn (Collection $methods) => $methods->contains($methodName)); 67 | } 68 | 69 | public function addMethods(string $methods): void 70 | { 71 | if (empty($methods)) { 72 | return; 73 | } 74 | 75 | $lines = []; 76 | $currentLine = 0; 77 | $endLine = $this->getEndLine(); 78 | $filePath = $this->getFileName(); 79 | 80 | foreach ($this->filesystem->lines($filePath) as $line) { 81 | $currentLine++; 82 | if ($currentLine === $endLine) { 83 | $lines[] = ""; 84 | $lines[] = " $methods"; 85 | $lines[] = "}"; 86 | 87 | break; 88 | } 89 | 90 | $lines[] = $line; 91 | } 92 | 93 | $contents = implode(PHP_EOL, $lines); 94 | 95 | $this->filesystem->put($filePath, $contents); 96 | } 97 | 98 | public function hasMethod(string $methodName): bool 99 | { 100 | return $this->getMethods()->has($methodName); 101 | } 102 | 103 | public function getStartLine(bool $withoutComments = false): int 104 | { 105 | $comments = $this->ref->getDocComment(); 106 | if ($withoutComments || !$comments) { 107 | return $this->ref->getStartLine(); 108 | } 109 | 110 | return $this->ref->getStartLine() - count(explode("\n", $comments)); 111 | } 112 | 113 | public function getEndLine(): int 114 | { 115 | return $this->ref->getEndLine(); 116 | } 117 | 118 | public function getFileName(): string 119 | { 120 | return $this->ref->getFileName(); 121 | } 122 | 123 | public function getContentWithAdditionalMethods(string $additionalMethods, array &$namespaces = []): string 124 | { 125 | $currentLine = 0; 126 | $classContent = ''; 127 | $classEndLine = $this->getEndLine(); 128 | $classStartLine = $this->getStartLine(); 129 | 130 | foreach ($this->filesystem->lines($this->getFileName()) as $line) { 131 | $currentLine++; 132 | 133 | if ($currentLine < $classStartLine) { 134 | preg_match(static::NAMESPACE_LINE_PATTERN, $line, $matches); 135 | $namespace = $matches[1] ?? null; 136 | if ($namespace && !in_array($namespace, $namespaces)) { 137 | $namespaces[$namespace] = $namespace; 138 | } 139 | 140 | continue; 141 | } 142 | 143 | if ($currentLine === $classEndLine) { 144 | $additionalMethods = $this->isEmpty() ? ltrim($additionalMethods, "\n") : $additionalMethods; 145 | $classContent .= $additionalMethods . $line; 146 | 147 | break; 148 | } 149 | 150 | $classContent .= "$line\n"; 151 | } 152 | 153 | return $classContent; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Generators/RequestsGenerator.php: -------------------------------------------------------------------------------- 1 | options['requests']['namespace'] ?? null; 19 | if (!is_array($namespaceData)) { 20 | throw new InvalidArgumentException("RequestsGenerator must be configured with array as 'namespace'"); 21 | } 22 | 23 | $requests = $this->extractRequests($specObject, $namespaceData); 24 | $this->createRequestsFiles($requests, $this->templatesManager->getTemplate('Request.template')); 25 | } 26 | 27 | protected function extractRequests(SpecObjectInterface $specObject, array $namespaceData): array 28 | { 29 | $replaceFromNamespace = array_keys($namespaceData)[0]; 30 | $replaceToNamespace = array_values($namespaceData)[0]; 31 | 32 | $openApiData = $specObject->getSerializableData(); 33 | 34 | $requests = []; 35 | $paths = $openApiData->paths ?: []; 36 | foreach ($paths as $routes) { 37 | foreach ($routes as $method => $route) { 38 | if (!in_array(strtoupper($method), $this->methods) || !empty($route->{'x-lg-skip-request-generation'})) { 39 | continue; 40 | } 41 | 42 | if (empty($route->{'x-lg-handler'})) { 43 | continue; 44 | } 45 | 46 | $handler = $this->routeHandlerParser->parse($route->{'x-lg-handler'}); 47 | 48 | try { 49 | $newNamespace = $this->getReplacedNamespace($handler->namespace, $replaceFromNamespace, $replaceToNamespace); 50 | } catch (RuntimeException) { 51 | continue; 52 | } 53 | 54 | $className = $route->{'x-lg-request-class-name'} ?? ucfirst($route->operationId) . 'Request'; 55 | if (!$className) { 56 | continue; 57 | } 58 | 59 | list($className, $newNamespace) = $this->getActualClassNameAndNamespace($className, $newNamespace); 60 | 61 | $validationRules = '//'; 62 | $usesEnums = ''; 63 | if (std_object_has($route, 'requestBody')) { 64 | $contentType = OpenApi3ContentTypeEnum::tryFrom(array_keys(get_object_vars($route->requestBody->content))[0]); 65 | if ($contentType) { 66 | try { 67 | [$validationRules, $usesEnums] = $this->getPropertyRules($contentType, $route->requestBody); 68 | } catch (Throwable $e) { 69 | console_warning("$className didn't generate", $e); 70 | } 71 | } 72 | } 73 | 74 | $requests[] = compact('className', 'newNamespace', 'validationRules', 'usesEnums'); 75 | } 76 | } 77 | 78 | return $requests; 79 | } 80 | 81 | protected function getPropertyRules(OpenApi3ContentTypeEnum $contentType, $requestBody): array 82 | { 83 | $request = new OpenApi3Schema(); 84 | $request->fillFromStdRequestBody($contentType, $requestBody); 85 | 86 | return $request->object->toLaravelValidationRules($this->options); 87 | } 88 | 89 | protected function createRequestsFiles(array $requests, string $template): void 90 | { 91 | foreach ($requests as [ 92 | 'className' => $className, 93 | 'newNamespace' => $newNamespace, 94 | 'validationRules' => $validationRules, 95 | 'usesEnums' => $usesEnums 96 | ]) { 97 | $filePath = $this->getNamespacedFilePath($className, $newNamespace); 98 | if ($this->filesystem->exists($filePath)) { 99 | continue; 100 | } 101 | 102 | $this->putWithDirectoryCheck( 103 | $filePath, 104 | $this->replacePlaceholders( 105 | $template, 106 | [ 107 | '{{ namespace }}' => $newNamespace, 108 | '{{ uses }}' => $usesEnums, 109 | '{{ className }}' => $className, 110 | '{{ rules }}' => $validationRules, 111 | ], 112 | true 113 | ) 114 | ); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Generators/TestsGenerator.php: -------------------------------------------------------------------------------- 1 | options['pest_tests']['namespace'] ?? null; 21 | if (!is_array($namespaceData)) { 22 | throw new InvalidArgumentException("TestsGenerator must be configured with array as 'namespace'"); 23 | } 24 | 25 | $openApiData = $specObject->getSerializableData(); 26 | $serversUrl = $openApiData?->servers[0]?->url ?? ''; 27 | $tests = $this->constructTests($openApiData, $namespaceData); 28 | $template = $this->templatesManager->getTemplate($this->getTemplateName()); 29 | 30 | $this->createTestsFiles($tests, $template, $serversUrl); 31 | } 32 | 33 | protected function constructTests(stdClass $openApiData, array $namespaceData): array 34 | { 35 | $replaceFromNamespace = array_keys($namespaceData)[0]; 36 | $replaceToNamespace = array_values($namespaceData)[0]; 37 | 38 | $tests = []; 39 | $paths = $openApiData->paths ?: []; 40 | foreach ($paths as $path => $routes) { 41 | foreach ($routes as $method => $route) { 42 | if (!empty($route->{'x-lg-skip-tests-generation'})) { 43 | continue; 44 | } 45 | 46 | if (empty($route->{'x-lg-handler'})) { 47 | continue; 48 | } 49 | 50 | $handler = $this->routeHandlerParser->parse($route->{'x-lg-handler'}); 51 | 52 | try { 53 | $newNamespace = $this->getReplacedNamespace($handler->namespace, $replaceFromNamespace, $replaceToNamespace); 54 | } catch (RuntimeException) { 55 | continue; 56 | } 57 | 58 | 59 | $className = str_replace("Controller", "", $handler->class) . "ComponentTest"; 60 | 61 | $firstResponse = null; 62 | if (isset($route->responses)) { 63 | $firstResponse = current((array)$route->responses) ?? null; 64 | } 65 | if (!$firstResponse) { 66 | continue; 67 | } 68 | 69 | $testFqcn = $handler->namespace . "\\" . $className; 70 | if (!isset($tests[$testFqcn])) { 71 | $tests[$testFqcn] = [ 72 | 'className' => $className, 73 | 'namespace' => $newNamespace, 74 | 'routes' => [], 75 | ]; 76 | } 77 | 78 | $tests[$testFqcn]['routes'][] = [ 79 | 'method' => $method, 80 | 'path' => $path, 81 | 'responseCodes' => $route->responses ? array_keys(get_object_vars($route->responses)) : [], 82 | 'responseContentType' => isset($firstResponse->content) ? array_keys(get_object_vars($firstResponse->content))[0] : "", 83 | ]; 84 | } 85 | } 86 | 87 | return $tests; 88 | } 89 | 90 | protected function createTestsFiles(array $testsData, string $template, $serversUrl): void 91 | { 92 | foreach ($testsData as ['className' => $className, 'namespace' => $namespace, 'routes' => $routes]) { 93 | $filePath = $this->getNamespacedFilePath($className, $namespace); 94 | if ($this->filesystem->exists($filePath)) { 95 | $newTests = $this->convertRoutesToTestsString($routes, $serversUrl, true); 96 | if (!empty($newTests)) { 97 | $data = <<filesystem->append($filePath, $data); 103 | } 104 | 105 | continue; 106 | } 107 | 108 | $this->putWithDirectoryCheck( 109 | $filePath, 110 | $this->replacePlaceholders($template, [ 111 | '{{ namespace }}' => $namespace, 112 | '{{ className }}' => $className, 113 | '{{ imports }}' => $this->convertRoutesToImportsString($routes), 114 | '{{ tests }}' => $this->convertRoutesToTestsString($routes, $serversUrl), 115 | ]) 116 | ); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Generators/BaseGenerator.php: -------------------------------------------------------------------------------- 1 | options = $options; 35 | 36 | return $this; 37 | } 38 | 39 | protected function replacePlaceholders(string $content, array $placeholders, bool $removeExcessLineBreaks = false): string 40 | { 41 | $placeholders = array_merge($placeholders, $this->formattedGlobalParams()); 42 | $content = str_replace(array_keys($placeholders), array_values($placeholders), $content); 43 | 44 | // Убираем двойные переносы строк 45 | if ($removeExcessLineBreaks) { 46 | $content = preg_replace("/([\n]+){3}/", "\n\n", $content); 47 | } 48 | 49 | return $content; 50 | } 51 | 52 | protected function trimPath(string $path): string 53 | { 54 | return $path === '/' ? $path : ltrim($path, '/'); 55 | } 56 | 57 | protected function getReplacedNamespace(?string $baseNamespace, string $replaceFromNamespace, string $replaceToNamespace): ?string 58 | { 59 | if ($baseNamespace) { 60 | return $this->replace($baseNamespace, $replaceFromNamespace, $replaceToNamespace) 61 | ?? throw new RuntimeException("Can't replace namespace"); 62 | } 63 | 64 | return null; 65 | } 66 | 67 | protected function getReplacedClassName(?string $baseClassName, string $replaceFromClassName, string $replaceToClassName): ?string 68 | { 69 | if ($baseClassName) { 70 | return $this->replace($baseClassName, $replaceFromClassName, $replaceToClassName) 71 | ?? throw new RuntimeException("Can't replace class name"); 72 | } 73 | 74 | return null; 75 | } 76 | 77 | protected function replace(string $base, string $from, string $to): ?string 78 | { 79 | if (!str_contains($base, $from)) { 80 | return null; 81 | } 82 | 83 | return str_replace($from, $to, $base); 84 | } 85 | 86 | protected function getNamespacedFilePath(string $fileName, ?string $namespace): string 87 | { 88 | $toDir = $this->psr4PathConverter->namespaceToPath($namespace); 89 | 90 | return rtrim($toDir, '/') . "/{$fileName}.php"; 91 | } 92 | 93 | protected function prepareDestinationDir(string $toDir): void 94 | { 95 | if (!$toDir || $toDir === '/') { 96 | throw new InvalidArgumentException("Destination directory cannot be empty or /"); 97 | } 98 | 99 | $this->filesystem->ensureDirectoryExists($toDir); 100 | $this->filesystem->cleanDirectory($toDir); 101 | } 102 | 103 | protected function putWithDirectoryCheck(string $path, string $contents): void 104 | { 105 | $this->filesystem->ensureDirectoryExists(dirname($path)); 106 | $this->filesystem->put($path, $contents); 107 | } 108 | 109 | private function formattedGlobalParams(): array 110 | { 111 | $params = []; 112 | foreach ($this->options['params'] ?? [] as $key => $value) { 113 | $params["{{ $key }}"] = $value; 114 | } 115 | 116 | return $params; 117 | } 118 | 119 | protected function getActualClassNameAndNamespace(?string $className, ?string $namespace): array 120 | { 121 | $parseClassName = explode('/', $className); 122 | 123 | if (count($parseClassName) > 1) { 124 | if (str_contains($namespace, '\Requests')) { 125 | $namespace = substr($namespace, 0, strpos($namespace, '\Requests') + 9); 126 | } elseif (str_contains($namespace, '\Resources')) { 127 | $namespace = substr($namespace, 0, strpos($namespace, '\Resources') + 10); 128 | } 129 | 130 | $className = array_pop($parseClassName); 131 | $namespace .= '\\' . implode('\\', $parseClassName); 132 | } 133 | 134 | return [ 135 | $className, 136 | $namespace, 137 | ]; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Generators/RoutesGenerator.php: -------------------------------------------------------------------------------- 1 | options['routes']['namespace'] ?? null; 13 | if (!is_string($namespaceData)) { 14 | throw new InvalidArgumentException("RoutesGenerator must be configured with string as 'namespace'"); 15 | } 16 | 17 | $namespace = rtrim($namespaceData, "\\"); 18 | $openApiData = $specObject->getSerializableData(); 19 | 20 | $routesStrings = ''; 21 | 22 | $controllerNamespaces = []; 23 | $paths = $openApiData->paths ?: []; 24 | foreach ($paths as $path => $routes) { 25 | foreach ($routes as $method => $route) { 26 | $handler = $route->{'x-lg-handler'} ?? null; 27 | $routeName = $route->{'x-lg-route-name'} ?? null; 28 | $routeMiddleware = $route->{'x-lg-middleware'} ?? null; 29 | $routeWithoutMiddleware = $route->{'x-lg-without-middleware'} ?? null; 30 | if ($handler) { 31 | $handler = $this->formatHandler($handler, $controllerNamespaces); 32 | 33 | $routesStrings .= "Route::{$method}('{$this->trimPath($path)}', {$handler})"; 34 | $routesStrings .= $routeName ? "->name('{$routeName}')" : ""; 35 | $routesStrings .= $routeMiddleware ? "->middleware({$this->formatMiddleware($routeMiddleware)})" : ""; 36 | $routesStrings .= $routeWithoutMiddleware ? "->withoutMiddleware({$this->formatMiddleware($routeWithoutMiddleware)})" : ""; 37 | $routesStrings .= ";\n"; 38 | } 39 | } 40 | } 41 | 42 | $controllerNamespacesStrings = $this->formatControllerNamespaces($controllerNamespaces); 43 | 44 | $routesPath = $this->getNamespacedFilePath("routes", $namespace); 45 | if ($this->filesystem->exists($routesPath)) { 46 | $this->filesystem->delete($routesPath); 47 | } 48 | 49 | $template = $this->templatesManager->getTemplate('routes.template'); 50 | $this->filesystem->put( 51 | $routesPath, 52 | $this->replacePlaceholders($template, [ 53 | '{{ controller_namespaces }}' => $controllerNamespacesStrings, 54 | '{{ routes }}' => $routesStrings, 55 | ]) 56 | ); 57 | } 58 | 59 | private function formatHandler(string $handler, array &$controllerNamespaces): string 60 | { 61 | $parsedRouteHandler = $this->routeHandlerParser->parse($handler); 62 | $method = $parsedRouteHandler->method; 63 | 64 | if (isset($controllerNamespaces[$parsedRouteHandler->class])) { 65 | if (isset($controllerNamespaces[$parsedRouteHandler->class]['items'][$parsedRouteHandler->namespace])) { 66 | $class = $controllerNamespaces[$parsedRouteHandler->class]['items'][$parsedRouteHandler->namespace]['class_name'] . '::class'; 67 | } else { 68 | $count = ++$controllerNamespaces[$parsedRouteHandler->class]['count']; 69 | $class = "{$parsedRouteHandler->class}{$count}"; 70 | $controllerNamespaces[$parsedRouteHandler->class]['items'][$parsedRouteHandler->namespace] = [ 71 | 'class_name' => $class, 72 | 'namespace' => "{$parsedRouteHandler->namespace}\\{$parsedRouteHandler->class} as {$class}", 73 | ]; 74 | $controllerNamespaces[$parsedRouteHandler->class]['count'] = $count; 75 | 76 | $class = $class . '::class'; 77 | } 78 | } else { 79 | $controllerNamespaces[$parsedRouteHandler->class] = [ 80 | 'items' => [ 81 | $parsedRouteHandler->namespace => [ 82 | 'class_name' => $parsedRouteHandler->class, 83 | 'namespace' => "{$parsedRouteHandler->namespace}\\{$parsedRouteHandler->class}", 84 | ], 85 | ], 86 | 'count' => 1, 87 | ]; 88 | 89 | $class = $parsedRouteHandler->class . '::class'; 90 | } 91 | 92 | return $method ? "[$class, '$method']" : "$class"; 93 | } 94 | 95 | private function formatMiddleware(string $middleware): string 96 | { 97 | $parts = array_map(function ($m) { 98 | $trimmedMiddleware = trim($m); 99 | 100 | return str_ends_with($trimmedMiddleware, '::class') ? "{$trimmedMiddleware}" : "'{$trimmedMiddleware}'"; 101 | }, explode(",", $middleware)); 102 | 103 | return '[' . implode(', ', $parts) . ']'; 104 | } 105 | 106 | private function formatControllerNamespaces(array $controllerNamespaces): string 107 | { 108 | $namespaces = []; 109 | foreach ($controllerNamespaces as $controllerNamespacesByClassName) { 110 | foreach ($controllerNamespacesByClassName['items'] as $controllerNamespace) { 111 | $namespaces[] = $controllerNamespace['namespace']; 112 | } 113 | } 114 | 115 | sort($namespaces, SORT_STRING | SORT_FLAG_CASE); 116 | 117 | return implode("\n", array_map(fn (string $namespace) => "use {$namespace};", $namespaces)); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Generators/PoliciesGenerator.php: -------------------------------------------------------------------------------- 1 | options['policies']['namespace'] ?? null; 17 | if (!is_array($namespaceData)) { 18 | throw new InvalidArgumentException("PoliciesGenerator must be configured with array as 'namespace'"); 19 | } 20 | 21 | $policies = $this->extractPolicies($specObject, $namespaceData); 22 | $this->createPoliciesFiles($policies, $this->templatesManager->getTemplate('Policy.template')); 23 | } 24 | 25 | protected function extractPolicies(SpecObjectInterface $specObject, array $namespaceData): array 26 | { 27 | $replaceFromNamespace = array_keys($namespaceData)[0]; 28 | $replaceToNamespace = array_values($namespaceData)[0]; 29 | 30 | $openApiData = $specObject->getSerializableData(); 31 | 32 | $policies = []; 33 | $paths = $openApiData->paths ?: []; 34 | foreach ($paths as $routes) { 35 | foreach ($routes as $route) { 36 | if (!$this->routeValidation($route)) { 37 | continue; 38 | } 39 | 40 | $handler = $this->routeHandlerParser->parse($route->{'x-lg-handler'}); 41 | if (!$this->handlerValidation($handler)) { 42 | continue; 43 | } 44 | 45 | try { 46 | $namespace = $this->getReplacedNamespace( 47 | $handler->namespace, 48 | $replaceFromNamespace, 49 | $replaceToNamespace 50 | ); 51 | } catch (RuntimeException) { 52 | continue; 53 | } 54 | 55 | $className = $handler->class . 'Policy'; 56 | $methods = [$handler->method]; 57 | 58 | if (isset($policies["$namespace\\$className"])) { 59 | $policies["$namespace\\$className"]['methods'][] = $methods[0]; 60 | } else { 61 | $policies["$namespace\\$className"] = compact('className', 'namespace', 'methods'); 62 | } 63 | } 64 | } 65 | 66 | return $policies; 67 | } 68 | 69 | protected function createPoliciesFiles(array $policies, string $template): void 70 | { 71 | foreach ($policies as ['className' => $className, 'namespace' => $namespace, 'methods' => $methods]) { 72 | $filePath = $this->getNamespacedFilePath($className, $namespace); 73 | if ($this->filesystem->exists($filePath)) { 74 | $class = $this->classParser->parse("$namespace\\$className"); 75 | 76 | $newPolicies = $this->convertMethodsToString($methods, $class); 77 | if (!empty($newPolicies)) { 78 | $class->addMethods($newPolicies); 79 | } 80 | 81 | continue; 82 | } 83 | 84 | $this->putWithDirectoryCheck( 85 | $filePath, 86 | $this->replacePlaceholders($template, [ 87 | '{{ namespace }}' => $namespace, 88 | '{{ className }}' => $className, 89 | '{{ methods }}' => $this->convertMethodsToString($methods), 90 | ]) 91 | ); 92 | } 93 | } 94 | 95 | private function routeValidation(stdClass $route): bool 96 | { 97 | return match (true) { 98 | !empty($route->{'x-lg-skip-policy-generation'}), 99 | empty($route->{'x-lg-handler'}), 100 | empty($route->responses->{403}) => false, 101 | default => true 102 | }; 103 | } 104 | 105 | private function handlerValidation(ParsedRouteHandler $handler): bool 106 | { 107 | return match (true) { 108 | empty($handler->namespace), 109 | empty($handler->class), 110 | empty($handler->method) => false, 111 | default => true 112 | }; 113 | } 114 | 115 | private function convertMethodsToString(array $methods, ?ClassParser $class = null): string 116 | { 117 | $methodsStrings = []; 118 | 119 | foreach ($methods as $method) { 120 | if ($class?->hasMethod($method)) { 121 | continue; 122 | } 123 | 124 | $methodsStrings[] = $this->replacePlaceholders( 125 | $this->templatesManager->getTemplate('PolicyGate.template'), 126 | ['{{ method }}' => $method] 127 | ); 128 | } 129 | 130 | if ($class) { 131 | $existMethods = $class->getMethods(); 132 | foreach ($existMethods as $methodName => $method) { 133 | if (!in_array($methodName, $methods) && !$class->isTraitMethod($methodName)) { 134 | $className = $class->getClassName(); 135 | console_warning("Warning: метод {$className}::{$methodName} отсутствует в спецификации или не может возвращать 403 ошибку"); 136 | } 137 | } 138 | } 139 | 140 | return implode("\n\n ", $methodsStrings); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Открытая лицензия на право использования программы для ЭВМ Greensight Ecom Platform (GEP) 2 | 3 | 1. Преамбула 4 | 5 | 1.1. Общество с ограниченной ответственностью «ГринСайт», в лице генерального директора Волкова Егора Владимировича, действующего на основании Устава, публикует условия публичной оферты о предоставлении открытой лицензии на право использования программы для ЭВМ Greensight Ecom Platform (GEP) (далее — Ensi) в соответствии с условиями ст. 1286.1 Гражданского кодекса РФ (далее — Оферта). 6 | 7 | 1.2. Правообладателем Ensi является ООО «ГринСайт» (далее — Правообладатель), в соответствии со свидетельством о государственной регистрации программы для ЭВМ № 2 020 663 096 от 22.10.2020 г. 8 | 9 | 1.3. В соответствии с пунктом 2 статьи 437 Гражданского кодекса РФ в случае принятия изложенных в Оферте условий Правообладателя, юридическое или физическое лицо, производящее акцепт Оферты, становится лицензиатом (в соответствии с пунктом 3 статьи 438 ГК РФ акцепт оферты равносилен заключению договора на условиях, изложенных в оферте), а Правообладатель и лицензиат совместно — сторонами лицензионного договора. 10 | 11 | 1.4. Вся документация, функциональные задания, сервисы и исходные коды Ensi размещены в сети Интернет по адресам: https://ensi.tech/ https://gitlab.com/greensight/ensi (далее — Сайт платформы). 12 | 13 | 1.5. Правообладатель является участником проекта «Сколково» и предоставляет права использования Ensi в рамках коммерциализации результатов своих исследований и разработок по направлению «стратегические компьютерные технологии и программное обеспечение». 14 | 15 | 2. Порядок акцепта оферты 16 | 17 | 2.1. Лицензионный договор, заключаемый на основании акцептирования лицензиатом Оферты (далее — Лицензионный договор), является договором присоединения, к которому лицензиат присоединяется без каких-либо исключений и/или оговорок. 18 | 19 | 2.2. Акцепт Оферты происходит в момент скачивания материалов Ensi с Сайта платформы. 20 | 21 | 2.3. Срок акцепта Оферты не ограничен. 22 | 23 | 3. Перечень прав использования Ensi 24 | 25 | 3.1. При соблюдении лицензиатом требований раздела 4 Оферты, предоставляемое право использования ENSI включает в себя: 26 | 27 | 3.1.1. Право использования Ensi на технических средствах лицензиата в соответствии с назначением Ensi, в том числе, все права использования, предусмотренные ст. 1280 Гражданского кодекса РФ; 28 | 29 | 3.1.2. Право на воспроизведение Ensi, не ограниченное правом его инсталляции и запуска; 30 | 31 | 3.1.3. Право на модификацию, адаптацию, внесение изменений и создание производных произведений (сложных произведений) с Ensi. 32 | 33 | 3.2. Лицензиату предоставляется право передачи третьим лицам прав, указанных в п. 3.1 Оферты (право сублицензирования). 34 | 35 | 3.3. Действие Лицензионного договора — территория всего мира. 36 | 37 | 3.4. Право использования Ensi предоставляется лицензиату на весь срок действия исключительных прав Правообладателя. 38 | 39 | 3.5. Право использования Ensi предоставляется безвозмездно. Лицензиат вправе использовать Ensi для создания производных произведений (сложных произведений) и их коммерческого применения, с учетом ограничений раздела 4 Оферты. 40 | 41 | 4. Обязанности лицензиата 42 | 43 | 4.1. Лицензиату предоставляются права указанные в разделе 3 Оферты при соблюдении им следующих условий: 44 | 45 | 4.1.1. Наличия письменного указания на авторство Правообладателя и ссылки на Сайт платформы при реализации третьим лицам Ensi (в коммерческих или некоммерческих целях), а также в любых созданных производных от Ensi произведениях. 46 | 47 | 4.1.2. Сохранения неизменным следующих частей кода Ensi: 48 | - в файле src/pages/_app.tsx строка — <meta name="generator" content="Ensi Platform" /> 49 | - в файле next.config.js строка — return [{ source: '/(.*)', headers: [{ key: 'X-Ensi-Platform', value: '1' }] }]; 50 | 51 | Удаление данных частей кода будет является существенным нарушением условий Оферты. 52 | 53 | 4.1.3. Использования Ensi в законных целях, а именно: не нарушающих законодательство Российской Федерации, норм международных договоров Российской Федерации, общепризнанных принципов и норм международного права. Не допускается использование Ensi в проектах, противоречащих принципам гуманности и морали, в распространении материалов и информации запрещенных в Российской Федерации. 54 | 55 | 4.2. При нарушении лицензиатом условий п. 4.1 Оферты, Правообладатель вправе в одностороннем порядке расторгнуть Лицензионный договор и потребовать мер защиты исключительных прав, включая положения ст.ст. 1252, 1301 Гражданского кодекса РФ. 56 | 57 | 4.3. Лицензиат дает Правообладателю согласие на указание своего фирменного наименования и логотипа на сайте Платформы. Правообладатель вправе использовать фирменное наименование и логотип Лицензиата в своих маркетинговых целях без дополнительного согласования с Лицензиатом. 58 | 59 | 5. Ограничение ответственности 60 | 61 | 5.1. Права использования Ensi предоставляются на условии «как есть» («as is») без какого-либо вида гарантий. Правообладатель не имеет обязательств перед лицензиатом по поддержанию функционирования Ensi в случае сбоя в работе, обеспечению отказоустойчивости и иных параметров, позволяющих использовать Ensi. Правообладатель не несет ответственности за любые убытки, упущенную выгоду, связанную с повреждением имущества, неполученным доходом, прерыванием коммерческой или производственной деятельности, возникшие вследствие использования Ensi лицензиатом. 62 | 63 | 6. Заключительные положения 64 | 65 | 6.1. Оферта вступает в силу с даты ее размещения на Сайте платформы и действует до момента прекращения исключительных прав на Ensi у Правообладателя. 66 | 67 | 6.2. Переход исключительного права на Ensi к новому правообладателю не будет являться основанием для изменения или расторжения Лицензионного договора. 68 | 69 | 6.3. К отношениям между Правообладателем и лицензиатом применяется право Российской Федерации. 70 | 71 | 6.4. Реквизиты Правообладателя: 72 | Общество с ограниченной ответственностью «ГринСайт» 73 | ОГРН 11 087 746 328 812 74 | ИНН 7 735 538 694 75 | КПП 773 501 001 -------------------------------------------------------------------------------- /tests/LaravelValidationRulesRequestTest.php: -------------------------------------------------------------------------------- 1 | makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 16 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 17 | 18 | $filesystem = $this->mock(Filesystem::class); 19 | $filesystem->shouldReceive('exists')->andReturn(false); 20 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 21 | return (bool)strstr($path, '.template'); 22 | })->andReturnUsing(function ($path) { 23 | return file_get_contents($path); 24 | }); 25 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 26 | $request = null; 27 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$request) { 28 | if (str_contains($path, 'LaravelValidationsApplicationJsonRequest.php')) { 29 | $request = $content; 30 | } 31 | 32 | return true; 33 | }); 34 | 35 | artisan(GenerateServer::class); 36 | 37 | $validationsStart = strpos($request, "public function rules(): array"); 38 | $validationsStart = strpos($request, " return", $validationsStart); 39 | $validationsEnd = strpos($request, '];', $validationsStart) + 2; 40 | $validations = substr($request, $validationsStart, $validationsEnd - $validationsStart); 41 | 42 | // For test on Windows replace \r\n to \n 43 | $actual = str_replace("\r\n", "\n", $validations); 44 | $expect = str_replace( 45 | "\r\n", 46 | "\n", 47 | file_get_contents(__DIR__ . '/expects/LaravelValidationsApplicationJsonRequest.php') 48 | ); 49 | 50 | assertEquals($expect, $actual, $validations); 51 | }); 52 | 53 | test('Check valid creating Laravel Validation Rules in Request with multipart/form-data content type', function () { 54 | /** @var TestCase $this */ 55 | $mapping = Config::get('openapi-server-generator.api_docs_mappings'); 56 | $mappingValue = current($mapping); 57 | $mapping = [$this->makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 58 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 59 | 60 | $filesystem = $this->mock(Filesystem::class); 61 | $filesystem->shouldReceive('exists')->andReturn(false); 62 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 63 | return (bool)strstr($path, '.template'); 64 | })->andReturnUsing(function ($path) { 65 | return file_get_contents($path); 66 | }); 67 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 68 | $request = null; 69 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$request) { 70 | if (str_contains($path, 'LaravelValidationsMultipartFormDataRequest.php')) { 71 | $request = $content; 72 | } 73 | 74 | return true; 75 | }); 76 | 77 | artisan(GenerateServer::class); 78 | 79 | $validationsStart = strpos($request, "public function rules(): array"); 80 | $validationsStart = strpos($request, " return", $validationsStart); 81 | $validationsEnd = strpos($request, '];', $validationsStart) + 2; 82 | $validations = substr($request, $validationsStart, $validationsEnd - $validationsStart); 83 | 84 | // For test on Windows replace \r\n to \n 85 | $actual = str_replace("\r\n", "\n", $validations); 86 | $expect = str_replace( 87 | "\r\n", 88 | "\n", 89 | file_get_contents(__DIR__ . '/expects/LaravelValidationsMultipartFormDataRequest.php') 90 | ); 91 | 92 | assertEquals($expect, $actual, $validations); 93 | }); 94 | 95 | test('Check valid creating Laravel Validation Rules in Request with non available content type', function () { 96 | /** @var TestCase $this */ 97 | $mapping = Config::get('openapi-server-generator.api_docs_mappings'); 98 | $mappingValue = current($mapping); 99 | $mapping = [$this->makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 100 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 101 | 102 | $filesystem = $this->mock(Filesystem::class); 103 | $filesystem->shouldReceive('exists')->andReturn(false); 104 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 105 | return (bool)strstr($path, '.template'); 106 | })->andReturnUsing(function ($path) { 107 | return file_get_contents($path); 108 | }); 109 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 110 | $request = null; 111 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$request) { 112 | if (str_contains($path, 'LaravelValidationsNonAvailableContentTypeRequest.php')) { 113 | $request = $content; 114 | } 115 | 116 | return true; 117 | }); 118 | 119 | artisan(GenerateServer::class); 120 | 121 | $validationsStart = strpos($request, "public function rules(): array"); 122 | $validationsStart = strpos($request, " return", $validationsStart); 123 | $validationsEnd = strpos($request, '];', $validationsStart) + 2; 124 | $validations = substr($request, $validationsStart, $validationsEnd - $validationsStart); 125 | 126 | // For test on Windows replace \r\n to \n 127 | $actual = str_replace("\r\n", "\n", $validations); 128 | $expect = str_replace( 129 | "\r\n", 130 | "\n", 131 | file_get_contents(__DIR__ . '/expects/LaravelValidationsNonAvailableContentTypeRequest.php') 132 | ); 133 | 134 | assertEquals($expect, $actual, $validations); 135 | }); 136 | -------------------------------------------------------------------------------- /src/Generators/ResourcesGenerator.php: -------------------------------------------------------------------------------- 1 | extractResources($specObject); 14 | $this->createResourcesFiles($resources, $this->templatesManager->getTemplate('Resource.template')); 15 | } 16 | 17 | protected function extractResources(SpecObjectInterface $specObject): array 18 | { 19 | $replaceFrom = 'Controller'; 20 | $replaceTo = 'Resource'; 21 | 22 | $openApiData = $specObject->getSerializableData(); 23 | 24 | $resources = []; 25 | $paths = $openApiData->paths ?: []; 26 | foreach ($paths as $routes) { 27 | foreach ($routes as $route) { 28 | if (!empty($route->{'x-lg-skip-resource-generation'})) { 29 | continue; 30 | } 31 | 32 | if (empty($route->{'x-lg-handler'})) { 33 | continue; 34 | } 35 | 36 | $response = $route->responses->{201} ?? $route->responses->{200} ?? null; 37 | if (!$response) { 38 | continue; 39 | } 40 | 41 | $responseSchema = $response->content?->{'application/json'}?->schema ?? null; 42 | if (!$responseSchema) { 43 | continue; 44 | } 45 | 46 | $handler = $this->routeHandlerParser->parse($route->{'x-lg-handler'}); 47 | 48 | try { 49 | $namespace = $this->getReplacedNamespace($handler->namespace, $replaceFrom, $replaceTo); 50 | $className = $responseSchema->{'x-lg-resource-class-name'} ?? $this->getReplacedClassName($handler->class, $replaceFrom, $replaceTo); 51 | } catch (RuntimeException) { 52 | continue; 53 | } 54 | 55 | list($className, $namespace) = $this->getActualClassNameAndNamespace($className, $namespace); 56 | 57 | if (isset($resources["$namespace\\$className"])) { 58 | continue; 59 | } 60 | 61 | $responseData = $responseSchema; 62 | 63 | $responseKey = $responseSchema->{'x-lg-resource-response-key'} ?? 64 | $this->options['resources']['response_key'] ?? 65 | null; 66 | if ($responseKey) { 67 | $responseKeyParts = explode('.', $responseKey); 68 | foreach ($responseKeyParts as $responseKeyPart) { 69 | $flag = false; 70 | do_with_all_of($responseData, function (stdClass $p) use (&$responseData, &$flag, $responseKeyPart, &$className) { 71 | if (std_object_has($p, 'properties')) { 72 | if (std_object_has($p->properties, $responseKeyPart)) { 73 | $responseData = $p->properties->$responseKeyPart; 74 | $flag = true; 75 | 76 | if (std_object_has($p->properties->$responseKeyPart, 'x-lg-resource-class-name')) { 77 | $className = $p->properties->$responseKeyPart->{'x-lg-resource-class-name'}; 78 | } 79 | } 80 | } 81 | }); 82 | 83 | if (!$flag) { 84 | $responseData = null; 85 | 86 | break; 87 | } 88 | } 89 | } 90 | 91 | if (!$responseData) { 92 | continue; 93 | } 94 | 95 | $properties = $this->convertToString($this->getProperties($responseData)); 96 | 97 | if (empty($properties)) { 98 | continue; 99 | } 100 | 101 | $resources["$namespace\\$className"] = compact('className', 'namespace', 'properties'); 102 | } 103 | } 104 | 105 | return $resources; 106 | } 107 | 108 | protected function createResourcesFiles(array $resources, string $template): void 109 | { 110 | foreach ($resources as ['className' => $className, 'namespace' => $namespace, 'properties' => $properties]) { 111 | $filePath = $this->getNamespacedFilePath($className, $namespace); 112 | if ($this->filesystem->exists($filePath)) { 113 | continue; 114 | } 115 | 116 | $this->putWithDirectoryCheck( 117 | $filePath, 118 | $this->replacePlaceholders($template, [ 119 | '{{ namespace }}' => $namespace, 120 | '{{ className }}' => $className, 121 | '{{ properties }}' => $properties, 122 | ]) 123 | ); 124 | } 125 | } 126 | 127 | private function getProperties(stdClass $object): array 128 | { 129 | $properties = []; 130 | 131 | do_with_all_of($object, function (stdClass $p) use (&$properties) { 132 | if (std_object_has($p, 'properties')) { 133 | $properties = array_merge($properties, array_keys(get_object_vars($p->properties))); 134 | } 135 | 136 | if (std_object_has($p, 'items')) { 137 | $properties = array_merge($properties, $this->getProperties($p->items)); 138 | } 139 | }); 140 | 141 | return $properties; 142 | } 143 | 144 | private function convertToString(array $properties): string 145 | { 146 | $propertyStrings = []; 147 | 148 | foreach ($properties as $property) { 149 | $propertyStrings[] = "'$property' => \$this->$property,"; 150 | } 151 | 152 | return implode("\n ", $propertyStrings); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/ClassParserTest.php: -------------------------------------------------------------------------------- 1 | mock(Filesystem::class); 12 | 13 | $parser = new ClassParser($filesystem); 14 | $parser->parse($namespace); 15 | 16 | expect($parser->isEmpty())->toBe($result); 17 | })->with([ 18 | [LaravelExistsController::class, false], 19 | [LaravelEmptyController::class, true], 20 | ]); 21 | 22 | test('ClassParser check getMethods success', function (string $namespace, array $result) { 23 | $filesystem = $this->mock(Filesystem::class); 24 | 25 | $parser = new ClassParser($filesystem); 26 | $parser->parse($namespace); 27 | 28 | $methods = $parser->getMethods()->keys()->toArray(); 29 | 30 | expect($methods)->toBe($result); 31 | })->with([ 32 | [LaravelExistsController::class, ['delete']], 33 | [LaravelEmptyController::class, []], 34 | ]); 35 | 36 | test('ClassParser check hasMethod success', function (string $namespace, string $method, bool $result) { 37 | $filesystem = $this->mock(Filesystem::class); 38 | 39 | $parser = new ClassParser($filesystem); 40 | $parser->parse($namespace); 41 | 42 | expect($parser->hasMethod($method))->toBe($result); 43 | })->with([ 44 | [LaravelExistsController::class, 'delete', true], 45 | [LaravelExistsController::class, 'search', false], 46 | [LaravelEmptyController::class, 'delete', false], 47 | [LaravelEmptyController::class, 'search', false], 48 | ]); 49 | 50 | test('ClassParser check getLines success', function (string $namespace, int $start, int $end) { 51 | $filesystem = $this->mock(Filesystem::class); 52 | 53 | $parser = new ClassParser($filesystem); 54 | $parser->parse($namespace); 55 | 56 | expect($parser->getStartLine())->toBe($start); 57 | expect($parser->getEndLine())->toBe($end); 58 | })->with([ 59 | [LaravelExistsController::class, 8, 14], 60 | [LaravelEmptyController::class, 5, 10], 61 | ]); 62 | 63 | test('ClassParser check isTraitMethod success', function (string $namespace, string $method, bool $result) { 64 | $filesystem = $this->mock(Filesystem::class); 65 | 66 | $parser = new ClassParser($filesystem); 67 | $parser->parse($namespace); 68 | 69 | expect($parser->isTraitMethod($method))->toBe($result); 70 | })->with([ 71 | [LaravelPolicy::class, 'allow', true], 72 | [LaravelPolicy::class, 'search', false], 73 | [LaravelPolicy::class, 'get', false], 74 | 75 | [LaravelWithoutTraitPolicy::class, 'allow', false], 76 | [LaravelWithoutTraitPolicy::class, 'search', false], 77 | [LaravelWithoutTraitPolicy::class, 'get', false], 78 | ]); 79 | 80 | test('ClassParser check getClassName success', function (string $namespace) { 81 | $filesystem = $this->mock(Filesystem::class); 82 | 83 | $parser = new ClassParser($filesystem); 84 | $parser->parse($namespace); 85 | 86 | expect($parser->getClassName())->toBe($namespace); 87 | })->with([ 88 | [LaravelPolicy::class], 89 | [LaravelExistsController::class], 90 | [LaravelEmptyController::class], 91 | ]); 92 | 93 | test('ClassParser check getFileName success', function (string $namespace) { 94 | $filesystem = $this->mock(Filesystem::class); 95 | 96 | $parser = new ClassParser($filesystem); 97 | $parser->parse($namespace); 98 | 99 | $class = last(explode("\\", $namespace)); 100 | 101 | expect($parser->getFileName())->toBe(realpath(__DIR__ . "/expects/Controllers/{$class}.php")); 102 | })->with([ 103 | [LaravelExistsController::class], 104 | [LaravelEmptyController::class], 105 | ]); 106 | 107 | test('ClassParser check getContentWithAdditionalMethods success', function ( 108 | string $namespace, 109 | string $expect, 110 | string $additional = "", 111 | array $namespaces = [], 112 | array $expectNamespaces = [], 113 | ) { 114 | $class = last(explode("\\", $namespace)); 115 | 116 | /** @var \Mockery\Mock|Filesystem $filesystem */ 117 | $filesystem = $this->mock(Filesystem::class); 118 | $filesystem 119 | ->shouldReceive('lines') 120 | ->andReturn(file(__DIR__ . "/expects/Controllers/{$class}.php", FILE_IGNORE_NEW_LINES)); 121 | 122 | $parser = new ClassParser($filesystem); 123 | $parser->parse($namespace); 124 | 125 | $content = $parser->getContentWithAdditionalMethods($additional, $namespaces); 126 | 127 | $expectPathFile = __DIR__ . "/expects/Controllers/{$expect}.expect"; 128 | $expectResult = implode("\n", file($expectPathFile, FILE_IGNORE_NEW_LINES)); 129 | 130 | expect($content)->toBe($expectResult); 131 | 132 | expect($namespaces)->toBe($expectNamespaces); 133 | })->with([ 134 | [ 135 | LaravelExistsController::class, 136 | 'LaravelExists_1_Controller', 137 | "\n public function test() {}\n", 138 | [ 139 | "App\Http\ApiV1\Support\Resources\EmptyResource" => "App\Http\ApiV1\Support\Resources\EmptyResource", 140 | ], 141 | [ 142 | "App\Http\ApiV1\Support\Resources\EmptyResource" => "App\Http\ApiV1\Support\Resources\EmptyResource", 143 | "Illuminate\Contracts\Support\Responsable" => "Illuminate\Contracts\Support\Responsable", 144 | "Illuminate\Http\Request" => "Illuminate\Http\Request", 145 | ], 146 | ], 147 | [ 148 | LaravelExistsController::class, 149 | 'LaravelExists_2_Controller', 150 | "", 151 | [], 152 | [ 153 | "Illuminate\Contracts\Support\Responsable" => "Illuminate\Contracts\Support\Responsable", 154 | "Illuminate\Http\Request" => "Illuminate\Http\Request", 155 | ], 156 | ], 157 | [LaravelEmptyController::class, 'LaravelEmpty_1_Controller'], 158 | [LaravelEmptyController::class, 'LaravelEmpty_2_Controller', "\n public function test() {}\n"], 159 | ]); 160 | -------------------------------------------------------------------------------- /src/Data/OpenApi3/OpenApi3ObjectProperty.php: -------------------------------------------------------------------------------- 1 | nullable = true; 29 | } 30 | if (std_object_has($stdProperty, 'format')) { 31 | $this->format = $stdProperty->format; 32 | } 33 | if (std_object_has($stdProperty, 'x-lg-enum-class')) { 34 | $this->enumClass = $stdProperty->{'x-lg-enum-class'}; 35 | } 36 | 37 | if (std_object_has($stdProperty, 'type')) { 38 | switch (OpenApi3PropertyTypeEnum::from($stdProperty->type)) { 39 | case OpenApi3PropertyTypeEnum::OBJECT: 40 | $this->object = new OpenApi3Object(); 41 | $this->object->fillFromStdObject($stdProperty); 42 | 43 | break; 44 | case OpenApi3PropertyTypeEnum::ARRAY: 45 | if (std_object_has($stdProperty, 'items')) { 46 | do_with_all_of($stdProperty->items, function (stdClass $p) use ($propertyName) { 47 | if (!$this->items && std_object_has($p, 'type')) { 48 | $this->items = new OpenApi3ObjectProperty(type: $p->type); 49 | } 50 | $this->items?->fillFromStdProperty("{$propertyName}.*", $p); 51 | }); 52 | } 53 | 54 | break; 55 | default: 56 | } 57 | } 58 | } 59 | 60 | public function getLaravelValidationsAndEnums(array $options, array &$validations = [], array &$enums = [], ?string $namePrefix = null): array 61 | { 62 | $name = "{$namePrefix}{$this->name}"; 63 | 64 | if ($this->required) { 65 | $validations[$name][] = "'required'"; 66 | } 67 | if ($this->nullable) { 68 | $validations[$name][] = "'nullable'"; 69 | } 70 | if ($this->enumClass) { 71 | $validations[$name][] = "new Enum({$this->enumClass}::class)"; 72 | $enums[$this->enumClass] = true; 73 | } else { 74 | [$currentValidations, $currentEnums] = $this->getValidationsAndEnumsByTypeAndFormat($options, $validations, $enums, $name); 75 | $validations = array_merge($validations, $currentValidations); 76 | $enums = array_merge($enums, $currentEnums); 77 | } 78 | 79 | return [$validations, $enums]; 80 | } 81 | 82 | protected function getValidationsAndEnumsByTypeAndFormat(array $options, array &$validations, array &$enums, string $name): array 83 | { 84 | $type = OpenApi3PropertyTypeEnum::from($this->type); 85 | $format = OpenApi3PropertyFormatEnum::tryFrom($this->format); 86 | switch ($type) { 87 | case OpenApi3PropertyTypeEnum::INTEGER: 88 | case OpenApi3PropertyTypeEnum::BOOLEAN: 89 | case OpenApi3PropertyTypeEnum::NUMBER: 90 | $validations[$name][] = "'{$type->toLaravelValidationRule()->value}'"; 91 | 92 | break; 93 | case OpenApi3PropertyTypeEnum::STRING: 94 | switch ($format) { 95 | case OpenApi3PropertyFormatEnum::DATE: 96 | case OpenApi3PropertyFormatEnum::DATE_TIME: 97 | case OpenApi3PropertyFormatEnum::PASSWORD: 98 | case OpenApi3PropertyFormatEnum::EMAIL: 99 | case OpenApi3PropertyFormatEnum::IPV4: 100 | case OpenApi3PropertyFormatEnum::IPV6: 101 | case OpenApi3PropertyFormatEnum::TIMEZONE: 102 | case OpenApi3PropertyFormatEnum::PHONE: 103 | case OpenApi3PropertyFormatEnum::URL: 104 | case OpenApi3PropertyFormatEnum::UUID: 105 | $validations[$name][] = "'{$format->toLaravelValidationRule()->value}'"; 106 | 107 | break; 108 | case OpenApi3PropertyFormatEnum::BINARY: 109 | $validations[$name][] = "'" . LaravelValidationRuleEnum::FILE->value . "'"; 110 | 111 | break; 112 | default: 113 | $validations[$name][] = "'{$type->toLaravelValidationRule()->value}'"; 114 | 115 | break; 116 | } 117 | 118 | break; 119 | case OpenApi3PropertyTypeEnum::OBJECT: 120 | foreach ($this->object->properties ?? [] as $property) { 121 | [$currentValidations, $currentEnums] = $property->getLaravelValidationsAndEnums($options, $validations, $enums, "{$name}."); 122 | $validations = array_merge($validations, $currentValidations); 123 | $enums = array_merge($enums, $currentEnums); 124 | } 125 | 126 | break; 127 | case OpenApi3PropertyTypeEnum::ARRAY: 128 | $validations[$name][] = "'{$type->toLaravelValidationRule()->value}'"; 129 | [$currentValidations, $currentEnums] = $this->items->getLaravelValidationsAndEnums($options, $validations, $enums, "{$name}.*"); 130 | $validations = array_merge($validations, $currentValidations); 131 | $enums = array_merge($enums, $currentEnums); 132 | } 133 | 134 | return [$validations, $enums]; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tests/resources/schemas/test_generation_request_validation.yaml: -------------------------------------------------------------------------------- 1 | #Objects 2 | ResourceReadonlyForTestValidationRules: 3 | type: object 4 | properties: 5 | field_integer_readonly: 6 | type: integer 7 | description: Поле типа integer 8 | field_boolean_readonly: 9 | type: boolean 10 | description: Поле типа boolean 11 | field_number_readonly: 12 | type: number 13 | description: Поле типа number 14 | field_enum_readonly: 15 | type: integer 16 | description: Поле, значение которого задается TestIntegerEnum 17 | x-lg-enum-class: 'TestIntegerEnum' 18 | example: 1 19 | field_array_readonly: 20 | type: array 21 | description: Поле типа array 22 | items: 23 | type: object 24 | properties: 25 | field: 26 | type: integer 27 | field_array_allOf_readonly: 28 | type: array 29 | description: Поле типа array с allOf 30 | items: 31 | allOf: 32 | - type: string 33 | - type: string 34 | x-lg-enum-class: 'TestStringEnum' 35 | field_allOf_readonly: 36 | allOf: 37 | - type: string 38 | - type: string 39 | x-lg-enum-class: 'TestStringEnum' 40 | field_object_readonly: 41 | type: object 42 | description: Поле типа object 43 | properties: 44 | field: 45 | type: integer 46 | 47 | ResourceFillableForTestValidationRules: 48 | type: object 49 | properties: 50 | field_integer_fillable: 51 | type: integer 52 | description: Поле типа integer 53 | field_integer_double_fillable: 54 | type: integer 55 | format: double 56 | description: Поле типа integer формат double (не поддерживается) 57 | field_string_fillable: 58 | type: string 59 | description: Поле типа string 60 | field_string_date_fillable: 61 | type: string 62 | format: date 63 | description: Поле типа string с форматом date 64 | field_string_password_fillable: 65 | type: string 66 | format: password 67 | description: Поле типа string с форматом password 68 | field_string_byte_fillable: 69 | type: string 70 | format: byte 71 | description: Поле типа string с форматом byte 72 | field_string_binary_fillable: 73 | type: string 74 | format: binary 75 | description: Поле типа string с форматом binary 76 | field_string_email_fillable: 77 | type: string 78 | format: email 79 | description: Поле типа string с форматом email 80 | field_string_ipv4_fillable: 81 | type: string 82 | format: ipv4 83 | description: Поле типа string с форматом ipv4 84 | field_string_ipv6_fillable: 85 | type: string 86 | format: ipv6 87 | description: Поле типа string с форматом ipv6 88 | field_string_timezone_fillable: 89 | type: string 90 | format: timezone 91 | description: Поле типа string с форматом timezone 92 | field_string_phone_fillable: 93 | type: string 94 | format: phone 95 | description: Поле типа string с форматом phone 96 | field_string_url_fillable: 97 | type: string 98 | format: url 99 | description: Поле типа string с форматом url 100 | field_string_uuid_fillable: 101 | type: string 102 | format: uuid 103 | description: Поле типа string с форматом uuid 104 | field_boolean_fillable: 105 | type: boolean 106 | description: Поле типа boolean 107 | field_number_fillable: 108 | type: number 109 | description: Поле типа number 110 | field_enum_fillable: 111 | type: integer 112 | description: Поле, значение которого задается TestIntegerEnum 113 | x-lg-enum-class: 'TestIntegerEnum' 114 | example: 1 115 | field_array_fillable: 116 | type: array 117 | description: Поле типа array 118 | items: 119 | type: object 120 | properties: 121 | field: 122 | type: integer 123 | field_object_fillable: 124 | type: object 125 | description: Поле типа object 126 | properties: 127 | field: 128 | type: integer 129 | 130 | field_integer_required_fillable: 131 | type: integer 132 | description: Поле типа integer 133 | field_string_required_fillable: 134 | type: string 135 | description: Поле типа string 136 | field_boolean_required_fillable: 137 | type: boolean 138 | description: Поле типа boolean 139 | field_number_required_fillable: 140 | type: number 141 | description: Поле типа number 142 | field_enum_required_fillable: 143 | type: integer 144 | description: Поле, значение которого задается TestIntegerEnum 145 | x-lg-enum-class: 'TestIntegerEnum' 146 | example: 1 147 | field_array_required_fillable: 148 | type: array 149 | description: Поле типа array 150 | items: 151 | type: object 152 | properties: 153 | field: 154 | type: integer 155 | field_object_required_fillable: 156 | type: object 157 | description: Поле типа object 158 | properties: 159 | field: 160 | type: integer 161 | 162 | field_integer_nullable_fillable: 163 | type: integer 164 | description: Поле типа integer 165 | nullable: true 166 | field_string_nullable_fillable: 167 | type: string 168 | description: Поле типа string 169 | nullable: true 170 | field_boolean_nullable_fillable: 171 | type: boolean 172 | description: Поле типа boolean 173 | nullable: true 174 | field_number_nullable_fillable: 175 | type: number 176 | description: Поле типа number 177 | nullable: true 178 | field_enum_nullable_fillable: 179 | type: integer 180 | description: Поле, значение которого задается TestIntegerEnum 181 | x-lg-enum-class: 'TestIntegerEnum' 182 | example: 1 183 | nullable: true 184 | field_array_nullable_fillable: 185 | type: array 186 | description: Поле типа array 187 | items: 188 | type: object 189 | properties: 190 | field: 191 | type: integer 192 | nullable: true 193 | field_object_nullable_fillable: 194 | type: object 195 | description: Поле типа object 196 | properties: 197 | field: 198 | type: integer 199 | nullable: true 200 | 201 | ResourceRequiredForTestValidationRules: 202 | type: object 203 | required: 204 | - field_integer_required_fillable 205 | - field_string_required_fillable 206 | - field_boolean_required_fillable 207 | - field_number_required_fillable 208 | - field_enum_required_fillable 209 | - field_array_required_fillable 210 | - field_object_required_fillable 211 | 212 | #Requests 213 | ResourceForTestValidationRules: 214 | allOf: 215 | - $ref: '#/ResourceReadonlyForTestValidationRules' 216 | - $ref: '#/ResourceFillableForTestValidationRules' 217 | - $ref: '#/ResourceRequiredForTestValidationRules' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel OpenApi Server Generator 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/ensi/laravel-openapi-server-generator.svg?style=flat-square)](https://packagist.org/packages/ensi/laravel-openapi-server-generator) 4 | [![Tests](https://github.com/ensi-platform/laravel-php-rdkafka/actions/workflows/run-tests.yml/badge.svg?branch=master)](https://github.com/ensi-platform/laravel-php-rdkafka/actions/workflows/run-tests.yml) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/ensi/laravel-openapi-server-generator.svg?style=flat-square)](https://packagist.org/packages/ensi/laravel-openapi-server-generator) 6 | 7 | Generates Laravel application code from Open Api Specification files 8 | 9 | ## Installation 10 | 11 | You can install the package via composer: 12 | 13 | ```bash 14 | composer require ensi/laravel-openapi-server-generator --dev 15 | ``` 16 | 17 | Publish the config file with: 18 | ```bash 19 | php artisan vendor:publish --provider="Ensi\LaravelOpenApiServerGenerator\LaravelOpenApiServerGeneratorServiceProvider" 20 | ``` 21 | 22 | and configure all the options. 23 | 24 | ### Migrating from version 0.x.x 25 | 26 | Delete `config/openapi-server-generator.php`, republish it using command above and recreate desired configuration. 27 | 28 | ## Version Compatibility 29 | 30 | | Laravel OpenApi Server Generator | Laravel | PHP | 31 | |----------------------------------|----------------------------|------------------| 32 | | ^0.0.2 - ^0.8.2 | ^7.x | ^7.1.3 | 33 | | ^0.8.3 - ^0.9.0 | ^7.x \|\| ^8.x | ^7.1.3 \|\| ^8.0 | 34 | | ^1.0.0 - ^1.1.2 | * | ^8.0 | 35 | | ^2.0.0 - ^3.0.3 | * | ^8.1 | 36 | | ^4.0.0 | ^9.x \|\| ^10.x \|\| ^11.x | ^8.1 | 37 | 38 | #### Basic Usage 39 | 40 | Run `php artisan openapi:generate-server`. It will generate all the configured entities from you OAS3 files. 41 | Override `default_entities_to_generate` configiration with `php artisan openapi:generate-server -e routes,enums` 42 | Make output more versbose: `php artisan openapi:generate-server -v` 43 | 44 | ## Overwriting templates 45 | 46 | You can also adjust file templates according to your needs. 47 | 1. Find the needed template inside `templates` directory in this repo; 48 | 2. Copy it to to `resources/openapi-server-generator/templates` directory inside your application or configure package to use another directory via `extra_templates_path` option; 49 | 3. Change whatever you need. 50 | 51 | ## Existing entities and generators 52 | 53 | ### 'routes' => RoutesGenerator::class 54 | 55 | Generates laravel route file (`route.php`) for each endpoint in `oas3->paths` 56 | The following [extension properties](https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#specificationExtensions) are used by this generator: 57 | 58 | ``` 59 | x-lg-handler: '\App\Http\Controllers\CustomersController@create' // Optional. Path is ignored if this field is empty. You can use :: instead of @ if you want 60 | x-lg-route-name: 'createCustomer' // Optional. Translates to `->name('createCustomer')` 61 | x-lg-middleware: '\App\Http\Middleware\Authenticate::class,web' // Optional. Translates to `->middleware([\App\Http\Middleware\Authenticate::class, 'web'])` 62 | x-lg-without-middleware: '\App\Http\Middleware\Authenticate::class,web' // Optional. Translates to `->withoutMiddleware([\App\Http\Middleware\Authenticate::class, 'web'])` 63 | ``` 64 | 65 | `route.php` file IS overriden with each generation. 66 | You should include it in your main route file like that: 67 | 68 | ```php 69 | $generatedRoutes = __DIR__ . "/OpenApiGenerated/routes.php"; 70 | if (file_exists($generatedRoutes)) { // prevents your app and artisan from breaking if there is no autogenerated route file for some reason. 71 | require $generatedRoutes; 72 | } 73 | ``` 74 | 75 | ### 'controllers' => ControllersGenerator::class 76 | 77 | Generates Controller class for each non-existing class specified in `x-lg-handler` 78 | Supports invocable Controllers. 79 | If several openapi paths point to several methods in one Controller/Handler then the generated class includes all of them. 80 | If a class already exists it is NOT overriden. 81 | Controller class IS meant to be modified after generation. 82 | 83 | ### 'requests' => RequestsGenerator::class 84 | 85 | Generates Laravel Form Requests for DELETE, PATCH, POST, PUT paths 86 | Destination must be configured with array as namespace instead of string. 87 | E.g. 88 | 89 | ```php 90 | 'requests' => [ 91 | 'namespace' => ["Controllers" => "Requests"] 92 | ], 93 | ``` 94 | 95 | This means "Get handler (x-lg-handler) namespace and replace Controllers with Requests in it" 96 | Form Request class IS meant to be modified after generation. You can treat it as a template generated with `php artisan make:request FooRequest` 97 | If the file already exists it IS NOT overriden with each generation. 98 | 99 | Form Request class name is `ucFirst(operationId)`. You can override it with `x-lg-request-class-name` 100 | You can skip generating form request for a give route with `x-lg-skip-request-generation: true` directive. 101 | 102 | When generating a request, the Laravel Validation rules for request fields are automatically generated. 103 | Validation rules generate based on the field type and required field. 104 | For fields whose values should only take the values of some enum, you must specify the `x-lg-enum-class option`. 105 | E.g.: `x-lg-enum-class: 'CustomerGenderEnum'`. 106 | 107 | ### 'enums' => EnumsGenerator::class 108 | 109 | Generates Enum class only for enums listed in `oas3->components->schemas`. 110 | Your need to specify `x-enum-varnames` field in each enum schema. The values are used as enum constants' names. 111 | Destination directory is cleared before generation to make sure all unused enum classes are deleted. 112 | Enums generator does NOT support `allOf`, `anyOf` and `oneOf` at the moment. 113 | 114 | ### 'pest_tests' => PestTestsGenerator::class 115 | 116 | Generates Pest test file for each `x-lg-handler` 117 | You can exclude oas3 path from test generation using `x-lg-skip-tests-generation: true`. 118 | If a test file already exists it is NOT overriden. 119 | Test file class IS meant to be modified after generation. 120 | 121 | ### 'resources' => ResourcesGenerator::class 122 | 123 | Generates Resource file for `x-lg-handler` 124 | Resource properties are generated relative to field in response, which can be set in the config 125 | ```php 126 | 'resources' => [ 127 | 'response_key' => 'data' 128 | ], 129 | ``` 130 | You can also specify `response_key` for resource: add `x-lg-resource-response-key: data` in object. 131 | When specifying `response_key`, you can use the "dot" syntax to specify nesting, for example `data.field` 132 | You can exclude resource generation using `x-lg-skip-resource-generation: true` in route. 133 | You can rename resource Class using `x-lg-resource-class-name: FooResource` in resource object or properties object. 134 | If a resource file already exists it is NOT overridden. 135 | Resource file contains a set of fields according to the specification. 136 | You also need to specify mixin DocBlock to autocomplete resource. 137 | 138 | ### 'policies' => PoliciesGenerator::class 139 | 140 | Generates Laravel Policies for routes. 141 | Destination must be configured with array as namespace instead of string. E.g: 142 | ```php 143 | 'policies' => [ 144 | 'namespace' => ["Controllers" => "Policies"] 145 | ], 146 | ``` 147 | * The path must contain a 403 response or the policy will not be generated. 148 | * You can exclude policy generation using `x-lg-skip-policy-generation: true` in route. 149 | * If a policy file already exists it is NOT overridden. 150 | 151 | ## Contributing 152 | 153 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 154 | 155 | ### Testing 156 | 157 | 1. composer install 158 | 2. composer test 159 | 160 | ## Security Vulnerabilities 161 | 162 | Please review [our security policy](.github/SECURITY.md) on how to report security vulnerabilities. 163 | 164 | ## License 165 | 166 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 167 | -------------------------------------------------------------------------------- /src/Generators/ControllersGenerator.php: -------------------------------------------------------------------------------- 1 | getSerializableData(); 22 | $this->serversUrl = $openApiData?->servers[0]?->url ?? ''; 23 | 24 | $controllers = $this->extractControllers($specObject); 25 | $this->createControllersFiles($controllers); 26 | } 27 | 28 | private function extractControllers(SpecObjectInterface $specObject): array 29 | { 30 | $openApiData = $specObject->getSerializableData(); 31 | 32 | $controllers = []; 33 | $paths = $openApiData->paths ?: []; 34 | foreach ($paths as $path => $routes) { 35 | foreach ($routes as $method => $route) { 36 | $requestClassName = null; 37 | $methodWithRequest = in_array(strtoupper($method), $this->methodsWithRequests); 38 | 39 | if (!empty($route->{'x-lg-skip-controller-generation'})) { 40 | continue; 41 | } 42 | 43 | if (empty($route->{'x-lg-handler'})) { 44 | continue; 45 | } 46 | 47 | $handler = $this->routeHandlerParser->parse($route->{'x-lg-handler'}); 48 | $fqcn = $handler->fqcn; 49 | if (!$fqcn) { 50 | continue; 51 | } 52 | 53 | if (!isset($controllers[$fqcn])) { 54 | $controllers[$fqcn] = [ 55 | 'className' => $handler->class, 56 | 'namespace' => $handler->namespace, 57 | 'actions' => [], 58 | 'requestsNamespaces' => [], 59 | ]; 60 | } 61 | 62 | if ($methodWithRequest && empty($route->{'x-lg-skip-request-generation'})) { 63 | $requestClassName = $route->{'x-lg-request-class-name'} ?? ucfirst($route->operationId) . 'Request'; 64 | $requestNamespace = $this->getReplacedNamespace($handler->namespace, 'Controllers', 'Requests'); 65 | 66 | list($requestClassName, $requestNamespace) = $this->getActualClassNameAndNamespace($requestClassName, $requestNamespace); 67 | $requestNamespace .= '\\' . ucfirst($requestClassName); 68 | 69 | $controllers[$fqcn]['requestsNamespaces'][$requestNamespace] = $requestNamespace; 70 | } 71 | 72 | $responses = $route->responses ?? null; 73 | $controllers[$fqcn]['actions'][] = [ 74 | 'name' => $handler->method ?: '__invoke', 75 | 'with_request_namespace' => $methodWithRequest && !empty($route->{'x-lg-skip-request-generation'}), 76 | 'parameters' => array_merge($this->extractPathParameters($route), $this->getActionExtraParameters($methodWithRequest, $requestClassName)), 77 | 78 | 'route' => [ 79 | 'method' => $method, 80 | 'path' => $path, 81 | 'responseCodes' => $responses ? array_keys(get_object_vars($responses)) : [], 82 | ], 83 | ]; 84 | } 85 | } 86 | 87 | return $controllers; 88 | } 89 | 90 | private function extractPathParameters(stdClass $route): array 91 | { 92 | $oasRoutePath = array_filter($route->parameters ?? [], fn (stdClass $param) => $param->in === "path"); 93 | 94 | return array_map(fn (stdClass $param) => [ 95 | 'name' => $param->name, 96 | 'type' => $this->typesMapper->openApiToPhp($param?->schema?->type ?? ''), 97 | ], $oasRoutePath); 98 | } 99 | 100 | private function getActionExtraParameters(bool $methodWithRequest, $requestClassName = null): array 101 | { 102 | if ($methodWithRequest) { 103 | return [[ 104 | 'name' => 'request', 105 | 'type' => $requestClassName ?? 'Request', 106 | ]]; 107 | } 108 | 109 | return []; 110 | } 111 | 112 | private function createControllersFiles(array $controllers): void 113 | { 114 | foreach ($controllers as $controller) { 115 | $namespace = $controller['namespace']; 116 | $className = $controller['className']; 117 | 118 | $filePath = $this->getNamespacedFilePath($className, $namespace); 119 | $controllerExists = $this->filesystem->exists($filePath); 120 | if (!$controllerExists) { 121 | $this->createEmptyControllerFile($filePath, $controller); 122 | } 123 | 124 | $class = $this->classParser->parse("$namespace\\$className"); 125 | 126 | $newMethods = $this->convertMethodsToString($class, $controller['actions'], $controller['requestsNamespaces']); 127 | if (!empty($newMethods)) { 128 | $controller['requestsNamespaces'][static::RESPONSABLE_NAMESPACE] = static::RESPONSABLE_NAMESPACE; 129 | } elseif ($controllerExists) { 130 | continue; 131 | } 132 | 133 | $content = $class->getContentWithAdditionalMethods($newMethods, $controller['requestsNamespaces']); 134 | 135 | $this->writeControllerFile($filePath, $controller, $content); 136 | } 137 | } 138 | 139 | protected function writeControllerFile(string $filePath, array $controller, string $classContent): void 140 | { 141 | $this->putWithDirectoryCheck( 142 | $filePath, 143 | $this->replacePlaceholders( 144 | $this->templatesManager->getTemplate('ControllerExists.template'), 145 | [ 146 | '{{ namespace }}' => $controller['namespace'], 147 | '{{ requestsNamespaces }}' => $this->formatRequestNamespaces($controller['requestsNamespaces']), 148 | '{{ classContent }}' => $classContent, 149 | ] 150 | ) 151 | ); 152 | } 153 | 154 | protected function createEmptyControllerFile(string $filePath, array $controller): void 155 | { 156 | $this->putWithDirectoryCheck( 157 | $filePath, 158 | $this->replacePlaceholders( 159 | $this->templatesManager->getTemplate('ControllerEmpty.template'), 160 | [ 161 | '{{ namespace }}' => $controller['namespace'], 162 | '{{ requestsNamespaces }}' => $this->formatRequestNamespaces($controller['requestsNamespaces']), 163 | '{{ className }}' => $controller['className'], 164 | ] 165 | ) 166 | ); 167 | } 168 | 169 | private function formatActionParamsAsString(array $params): string 170 | { 171 | return implode(', ', array_map(fn (array $param) => $param['type'] . " $" . $param['name'], $params)); 172 | } 173 | 174 | private function convertMethodsToString(ClassParser $class, array $methods, array &$namespaces): string 175 | { 176 | $methodsStrings = []; 177 | 178 | foreach ($methods as $method) { 179 | if ($class->hasMethod($method['name'])) { 180 | continue; 181 | } 182 | 183 | if ($method['with_request_namespace']) { 184 | $namespaces[static::REQUEST_NAMESPACE] = static::REQUEST_NAMESPACE; 185 | } 186 | 187 | $methodsStrings[] = $this->replacePlaceholders( 188 | $this->templatesManager->getTemplate('ControllerMethod.template'), 189 | [ 190 | '{{ method }}' => $method['name'], 191 | '{{ params }}' => $this->formatActionParamsAsString($method['parameters']), 192 | ] 193 | ); 194 | 195 | $this->controllersStorage->markNewControllerMethod( 196 | serversUrl: $this->serversUrl, 197 | path: $method['route']['path'], 198 | method: $method['route']['method'], 199 | responseCodes: $method['route']['responseCodes'], 200 | ); 201 | } 202 | 203 | $prefix = !empty($methodsStrings) ? static::DELIMITER : ''; 204 | 205 | return $prefix . implode(static::DELIMITER, $methodsStrings); 206 | } 207 | 208 | protected function formatRequestNamespaces(array $namespaces): string 209 | { 210 | $namespaces = array_values($namespaces); 211 | sort($namespaces, SORT_STRING | SORT_FLAG_CASE); 212 | 213 | return implode("\n", array_map(fn (string $namespaces) => "use {$namespaces};", $namespaces)); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /tests/resources/index.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Test 4 | version: 1.0.0 5 | description: Тестовый конфиг 6 | paths: 7 | /resources:test-rename-from-key-request: 8 | post: 9 | operationId: testRenameFromKeyRequest 10 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testRenameFromKeyRequest' 11 | responses: 12 | "200": 13 | description: Успешный ответ c контекстом 14 | content: 15 | application/json: 16 | schema: 17 | $ref: './schemas/test_resource_generation.yaml#/ResourceDataWithNameResponse' 18 | /resources:test-full-generate/{id}: 19 | post: 20 | operationId: testFullGenerate 21 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testFullGenerate' 22 | x-lg-middleware: 'middleware' 23 | x-lg-without-middleware: 'without-middleware' 24 | parameters: 25 | - name: id 26 | in: path 27 | responses: 28 | "200": 29 | description: Успешный ответ c контекстом 30 | content: 31 | application/json: 32 | schema: 33 | $ref: './schemas/test_resource_generation.yaml#/ResourceForTestResourceGenerationResponse' 34 | "500": 35 | $ref: '#/components/responses/ServerError' 36 | /resources:test-empty-rename-request: 37 | post: 38 | operationId: testEmptyRenameRequest 39 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testEmptyRenameRequest' 40 | x-lg-request-class-name: '' 41 | responses: 42 | "200": 43 | description: Успешный ответ c контекстом 44 | content: 45 | application/json: 46 | schema: 47 | $ref: './schemas/test_resource_generation.yaml#/ResourceDataDataResponse' 48 | /resources:test-rename-request: 49 | post: 50 | operationId: testRenameRequest 51 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testRenameRequest' 52 | x-lg-request-class-name: 'TestFooRenameRequest' 53 | responses: 54 | "200": 55 | description: Успешный ответ c контекстом 56 | content: 57 | application/json: 58 | schema: 59 | $ref: './schemas/test_resource_generation.yaml#/ResourceRootResponse' 60 | /resources:test-without-handler: 61 | post: 62 | operationId: testWithoutHandler 63 | responses: 64 | "200": 65 | description: Успешный ответ 66 | /resources:test-with-skip: 67 | post: 68 | operationId: testWithSkip 69 | x-lg-handler: '\App\Http\Controllers\SkipController@testWithSkip' 70 | x-lg-skip-resource-generation: true 71 | x-lg-skip-controller-generation: true 72 | x-lg-skip-request-generation: true 73 | x-lg-skip-tests-generation: true 74 | x-lg-skip-policy-generation: true 75 | responses: 76 | "200": 77 | description: Успешный ответ 78 | /resources:test-bad-handler: 79 | post: 80 | operationId: testBadHandler 81 | x-lg-handler: '' 82 | responses: 83 | "200": 84 | description: Успешный ответ 85 | /resources:test-global-namespace: 86 | post: 87 | operationId: withoutNamespace 88 | x-lg-handler: 'WithoutNamespaceController@testWithoutContext' 89 | responses: 90 | "200": 91 | description: Успешный ответ 92 | /resources:test-without-responses: 93 | post: 94 | operationId: testWithoutResponses 95 | x-lg-handler: '\App\Http\Controllers\WithoutResponsesController@testWithoutResponses' 96 | x-lg-skip-request-generation: true 97 | /resources:test-laravel-validations-application-json-request: 98 | post: 99 | operationId: laravelValidationsApplicationJson 100 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testLaravelValidationsApplicationJsonRequest' 101 | requestBody: 102 | required: true 103 | content: 104 | application/json: 105 | schema: 106 | $ref: './schemas/test_generation_request_validation.yaml#/ResourceForTestValidationRules' 107 | responses: 108 | "200": 109 | description: Успешный ответ 110 | /resources:test-laravel-validations-multipart-form-data-request: 111 | post: 112 | operationId: laravelValidationsMultipartFormData 113 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testLaravelValidationsMultipartFormDataRequest' 114 | requestBody: 115 | required: true 116 | content: 117 | multipart/form-data: 118 | schema: 119 | $ref: './common_schemas.yaml#/MultipartFileUploadRequest' 120 | responses: 121 | "200": 122 | description: Успешный ответ 123 | /resources:test-laravel-validations-non-available-content-type: 124 | post: 125 | operationId: laravelValidationsNonAvailableContentType 126 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testLaravelValidationsNonAvailableContentTypeRequest' 127 | requestBody: 128 | required: true 129 | content: 130 | text/plain: 131 | schema: 132 | type: string 133 | example: pong 134 | responses: 135 | "200": 136 | description: Успешный ответ 137 | /resources:test-generate-resource-bad-response-key: 138 | post: 139 | operationId: generateResourceBadResponseKey 140 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testGenerateResourceBadResponseKey' 141 | x-lg-skip-request-generation: true 142 | responses: 143 | "200": 144 | description: Успешный ответ c контекстом 145 | content: 146 | application/json: 147 | schema: 148 | $ref: './schemas/test_resource_generation.yaml#/GenerateResourceBadResponseKeyResponse' 149 | /resources:test-generate-without-properties: 150 | post: 151 | operationId: generateResourceWithoutProperties 152 | x-lg-handler: '\App\Http\Controllers\ResourcesController@testGenerateResourceWithoutProperties' 153 | x-lg-skip-request-generation: true 154 | responses: 155 | "200": 156 | description: Успешный ответ c контекстом 157 | content: 158 | application/json: 159 | schema: 160 | $ref: './schemas/test_resource_generation.yaml#/GenerateResourceWithoutPropertiesResponse' 161 | /resources:test-class-name-with-dir: 162 | post: 163 | operationId: testNamespaceWithDir 164 | x-lg-handler: '\App\Http\Controllers\Foo\TestController@testNamespaceWithDirRequest' 165 | x-lg-request-class-name: 'WithDirRequests/Request' 166 | responses: 167 | "200": 168 | description: Успешный ответ c контекстом 169 | content: 170 | application/json: 171 | schema: 172 | $ref: './schemas/test_resource_generation.yaml#/ResourceDataDataResponse' 173 | /resources:test-resource-class-name-with-dir: 174 | post: 175 | operationId: testNamespaceWithDir 176 | x-lg-handler: '\App\Http\Controllers\Foo\TestController@testNamespaceWithDirResource' 177 | responses: 178 | "200": 179 | description: Успешный ответ c контекстом 180 | content: 181 | application/json: 182 | schema: 183 | $ref: './schemas/test_resource_generation.yaml#/ResourceWithDirResponse' 184 | /policies:test-generate-policy-method-foo: 185 | post: 186 | operationId: generatePolicyMethodFoo 187 | x-lg-handler: '\App\Http\Controllers\PoliciesController@methodFoo' 188 | x-lg-skip-request-generation: true 189 | responses: 190 | "403": 191 | description: Ошибка прав доступа 192 | /policies:test-generate-policy-method-bar: 193 | post: 194 | operationId: generatePolicyMethodBar 195 | x-lg-handler: '\App\Http\Controllers\PoliciesController@methodBar' 196 | x-lg-skip-request-generation: true 197 | responses: 198 | "403": 199 | description: Ошибка прав доступа 200 | /policies:test-generate-policy-method-without-forbidden-response: 201 | post: 202 | operationId: generatePolicyMethodWithoutForbiddenResponse 203 | x-lg-handler: '\App\Http\Controllers\PoliciesController@methodWithoutForbiddenResponse' 204 | x-lg-skip-request-generation: true 205 | responses: 206 | "200": 207 | description: Успешный ответ c контекстом 208 | /namespace-sort-1: 209 | post: 210 | operationId: namespaceSort1 211 | x-lg-handler: '\App\Http\Controllers\FooItemsController@test' 212 | x-lg-skip-request-generation: true 213 | x-lg-skip-tests-generation: true 214 | responses: 215 | "200": 216 | description: Успешный ответ 217 | /namespace-sort-2: 218 | post: 219 | operationId: namespaceSort2 220 | x-lg-handler: '\App\Http\Controllers\FoosController@test' 221 | x-lg-skip-request-generation: true 222 | x-lg-skip-tests-generation: true 223 | responses: 224 | "200": 225 | description: Успешный ответ 226 | /namespace-sort-3: 227 | post: 228 | operationId: namespaceSort3 229 | x-lg-handler: '\App\Http\Controllers\Controller11@test' 230 | x-lg-skip-request-generation: true 231 | x-lg-skip-tests-generation: true 232 | responses: 233 | "200": 234 | description: Успешный ответ 235 | /namespace-sort-4: 236 | post: 237 | operationId: namespaceSort4 238 | x-lg-handler: '\App\Http\Controllers\Controller2@test' 239 | x-lg-skip-request-generation: true 240 | x-lg-skip-tests-generation: true 241 | responses: 242 | "200": 243 | description: Успешный ответ 244 | components: 245 | responses: 246 | ServerError: 247 | description: Internal Server Error 248 | content: 249 | application/json: 250 | schema: 251 | type: object 252 | properties: 253 | errors: 254 | type: array 255 | description: Массив ошибок 256 | required: 257 | - errors 258 | schemas: 259 | TestIntegerEnum: 260 | type: integer 261 | description: > 262 | Пример перечисления. Расшифровка значений: 263 | * `1` - Пример 1 264 | * `2` - Пример 2 265 | enum: 266 | - 1 267 | - 2 268 | x-enum-varnames: 269 | - EXAMPLE_1 270 | - EXAMPLE_2 271 | x-enum-descriptions: 272 | - Пример 1 273 | - Пример 2 274 | TestStringEnum: 275 | type: string 276 | description: > 277 | Пример перечисления. Расшифровка значений: 278 | * `example_1` - Пример 1 279 | * `example_2` - Пример 2 280 | enum: 281 | - example_1 282 | - example_2 283 | x-enum-varnames: 284 | - EXAMPLE_1 285 | - EXAMPLE_2 286 | x-enum-descriptions: 287 | - Пример 1 288 | - Пример 2 289 | 290 | 291 | 292 | -------------------------------------------------------------------------------- /tests/GenerateServerTest.php: -------------------------------------------------------------------------------- 1 | makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 19 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 20 | 21 | $filesystem = $this->mock(Filesystem::class); 22 | $filesystem->shouldReceive('exists')->andReturn(false); 23 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 24 | return (bool)strstr($path, '.template'); 25 | })->andReturnUsing(function ($path) { 26 | return file_get_contents($path); 27 | }); 28 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 29 | $appRoot = realpath($this->makeFilePath(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/')); 30 | $putFiles = []; 31 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$putFiles, $appRoot) { 32 | $filePath = $this->makeFilePath(str_replace($appRoot, '', $path)); 33 | $putFiles[$filePath] = $filePath; 34 | 35 | return true; 36 | }); 37 | 38 | artisan(GenerateServer::class); 39 | 40 | assertEqualsCanonicalizing([ 41 | $this->makeFilePath('/app/Http/ApiV1/OpenApiGenerated/routes.php'), 42 | 43 | $this->makeFilePath('/app/Http/Controllers/Foo/TestController.php'), 44 | $this->makeFilePath('/app/Http/Controllers/ResourcesController.php'), 45 | $this->makeFilePath('/app/Http/Requests/TestFullGenerateRequest.php'), 46 | $this->makeFilePath('/app/Http/Tests/ResourcesComponentTest.php'), 47 | $this->makeFilePath('/app/Http/Requests/TestFooRenameRequest.php'), 48 | 49 | $this->makeFilePath('/app/Http/Requests/WithDirRequests/Request.php'), 50 | $this->makeFilePath('/app/Http/Requests/Foo/TestNamespaceWithDirRequest.php'), 51 | $this->makeFilePath('/app/Http/Requests/LaravelValidationsApplicationJsonRequest.php'), 52 | $this->makeFilePath('/app/Http/Requests/LaravelValidationsMultipartFormDataRequest.php'), 53 | $this->makeFilePath('/app/Http/Requests/LaravelValidationsNonAvailableContentTypeRequest.php'), 54 | 55 | $this->makeFilePath('/app/Http/Controllers/WithoutResponsesController.php'), 56 | 57 | $this->makeFilePath('/WithoutNamespaceController.php'), 58 | $this->makeFilePath('/WithoutNamespaceRequest.php'), 59 | $this->makeFilePath('/WithoutNamespaceComponentTest.php'), 60 | 61 | $this->makeFilePath('/app/Http/ApiV1/OpenApiGenerated/Enums/TestIntegerEnum.php'), 62 | $this->makeFilePath('/app/Http/ApiV1/OpenApiGenerated/Enums/TestStringEnum.php'), 63 | 64 | $this->makeFilePath('/app/Http/Resources/ResourcesResource.php'), 65 | $this->makeFilePath('/app/Http/Resources/ResourcesDataDataResource.php'), 66 | $this->makeFilePath('/app/Http/Resources/Foo/ResourcesDataDataResource.php'), 67 | $this->makeFilePath('/app/Http/Resources/ResourceRootResource.php'), 68 | $this->makeFilePath('/app/Http/Resources/Foo/WithDirResource.php'), 69 | $this->makeFilePath('/app/Http/Tests/Foo/TestComponentTest.php'), 70 | 71 | $this->makeFilePath('/app/Http/Requests/TestRenameFromKeyRequestRequest.php'), 72 | $this->makeFilePath('/app/Http/Resources/ResourcesDataWithNameResource.php'), 73 | 74 | $this->makeFilePath('/app/Http/Controllers/Controller11.php'), 75 | $this->makeFilePath('/app/Http/Controllers/Controller2.php'), 76 | $this->makeFilePath('/app/Http/Controllers/FooItemsController.php'), 77 | $this->makeFilePath('/app/Http/Controllers/FoosController.php'), 78 | $this->makeFilePath('/app/Http/Controllers/PoliciesController.php'), 79 | $this->makeFilePath('/app/Http/Tests/PoliciesComponentTest.php'), 80 | $this->makeFilePath('/app/Http/Policies/PoliciesControllerPolicy.php'), 81 | ], array_values($putFiles)); 82 | }); 83 | 84 | test("Correct requests in controller methods", function () { 85 | /** @var TestCase $this */ 86 | $mapping = Config::get('openapi-server-generator.api_docs_mappings'); 87 | $mappingValue = current($mapping); 88 | $mapping = [$this->makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 89 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 90 | 91 | $filesystem = $this->mock(Filesystem::class); 92 | $filesystem->shouldReceive('exists')->andReturn(false); 93 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 94 | return (bool)strstr($path, '.template'); 95 | })->andReturnUsing(function ($path) { 96 | return file_get_contents($path); 97 | }); 98 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 99 | $resourceController = null; 100 | $withoutResponsesController = null; 101 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$resourceController, &$withoutResponsesController) { 102 | if (str_contains($path, 'ResourcesController.php')) { 103 | $resourceController = $content; 104 | } 105 | 106 | if (str_contains($path, 'WithoutResponsesController.php')) { 107 | $withoutResponsesController = $content; 108 | } 109 | 110 | return true; 111 | }); 112 | 113 | artisan(GenerateServer::class); 114 | 115 | assertNotTrue(is_null($resourceController), 'ResourceController exist'); 116 | assertStringContainsString( 117 | 'use App\Http\Requests\TestFooRenameRequest', 118 | $resourceController, 119 | 'ResourceController import' 120 | ); 121 | assertStringContainsString( 122 | 'TestFullGenerateRequest $request', 123 | $resourceController, 124 | 'ResourceController function parameter' 125 | ); 126 | 127 | assertNotTrue(is_null($withoutResponsesController), 'WithoutResponsesController exist'); 128 | assertStringContainsString( 129 | 'use Illuminate\Http\Request', 130 | $withoutResponsesController, 131 | 'WithoutResponsesController import' 132 | ); 133 | assertStringContainsString( 134 | 'Request $request', 135 | $withoutResponsesController, 136 | 'WithoutResponsesController function parameter' 137 | ); 138 | }); 139 | 140 | 141 | test('namespace sorting', function () { 142 | /** @var TestCase $this */ 143 | $mapping = Config::get('openapi-server-generator.api_docs_mappings'); 144 | $mappingValue = current($mapping); 145 | $mapping = [$this->makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 146 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 147 | 148 | $filesystem = $this->mock(Filesystem::class); 149 | $filesystem->shouldReceive('exists')->andReturn(false); 150 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 151 | return (bool)strstr($path, '.template'); 152 | })->andReturnUsing(function ($path) { 153 | return file_get_contents($path); 154 | }); 155 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 156 | $routes = ''; 157 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$routes, &$rr) { 158 | if (str_contains($path, 'routes.php')) { 159 | $routes = $content; 160 | } 161 | 162 | return true; 163 | }); 164 | 165 | artisan(GenerateServer::class, ['-e' => 'routes']); 166 | 167 | assertStringContainsString( 168 | "use App\Http\Controllers\Controller11;\n" . 169 | "use App\Http\Controllers\Controller2;\n" . 170 | "use App\Http\Controllers\Foo\TestController;\n" . 171 | "use App\Http\Controllers\FooItemsController;\n" . 172 | "use App\Http\Controllers\FoosController;\n", 173 | $routes 174 | ); 175 | }); 176 | 177 | test("Update tests success", function (array $parameters, bool $withControllerEntity) { 178 | /** @var TestCase $this */ 179 | $mapping = Config::get('openapi-server-generator.api_docs_mappings'); 180 | $mappingValue = current($mapping); 181 | $mapping = [$this->makeFilePath(__DIR__ . '/resources/index.yaml') => $mappingValue]; 182 | Config::set('openapi-server-generator.api_docs_mappings', $mapping); 183 | 184 | $appRoot = realpath($this->makeFilePath(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/')); 185 | 186 | $existTest = $this->makeFilePath('/app/Http/Tests/ResourcesComponentTest.php'); 187 | 188 | $filesystem = $this->mock(Filesystem::class); 189 | $filesystem->shouldReceive('exists')->andReturnUsing(function ($path) use ($appRoot, $existTest) { 190 | $filePath = $this->makeFilePath(str_replace($appRoot, '', $path)); 191 | 192 | return $filePath === $existTest; 193 | }); 194 | 195 | $filesystem->shouldReceive('get')->withArgs(function ($path) { 196 | return (bool)strstr($path, '.template'); 197 | })->andReturnUsing(function ($path) { 198 | return file_get_contents($path); 199 | }); 200 | $filesystem->shouldReceive('cleanDirectory', 'ensureDirectoryExists'); 201 | 202 | $putFiles = []; 203 | $filesystem->shouldReceive('put')->withArgs(function ($path, $content) use (&$putFiles, $appRoot) { 204 | $filePath = $this->makeFilePath(str_replace($appRoot, '', $path)); 205 | $putFiles[$filePath] = $filePath; 206 | 207 | return true; 208 | }); 209 | 210 | $appendFiles = []; 211 | $filesystem->shouldReceive('append')->withArgs(function ($filePath, $data) use (&$appendFiles, $appRoot, $existTest) { 212 | $filePath = $this->makeFilePath(str_replace($appRoot, '', $filePath)); 213 | $appendFiles[$filePath] = $data; 214 | 215 | return true; 216 | }); 217 | 218 | artisan(GenerateServer::class, $parameters); 219 | 220 | $appendData = [ 221 | 'POST /resources:test-generate-without-properties 200', 222 | 'POST /resources:test-empty-rename-request 200', 223 | 'POST /resources:test-rename-request 200', 224 | 'POST /resources:test-laravel-validations-application-json-request 200', 225 | 'POST /resources:test-laravel-validations-multipart-form-data-request 200', 226 | 'POST /resources:test-laravel-validations-non-available-content-type 200', 227 | 'POST /resources:test-generate-resource-bad-response-key 200', 228 | 'POST /resources:test-generate-without-properties 200', 229 | ]; 230 | 231 | assertEquals(isset($appendFiles[$existTest]), $withControllerEntity); 232 | 233 | if ($withControllerEntity) { 234 | $appendTestData = $appendFiles[$existTest]; 235 | foreach ($appendData as $data) { 236 | assertStringContainsString($data, $appendTestData); 237 | } 238 | } 239 | })->with([ 240 | [['-e' => 'pest_tests'], false], 241 | [['-e' => 'controllers,pest_tests'], true], 242 | [['-e' => 'pest_tests,controllers'], true], 243 | [[], true], 244 | ]); 245 | --------------------------------------------------------------------------------