├── 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 | [](https://packagist.org/packages/ensi/laravel-openapi-server-generator)
4 | [](https://github.com/ensi-platform/laravel-php-rdkafka/actions/workflows/run-tests.yml)
5 | [](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 |
--------------------------------------------------------------------------------