diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..285643a --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,42 @@ +name: run-tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.3, 8.2, 8.1 ] + laravel: [ 9.*, 10.* ] + stability: [ prefer-stable ] + include: + - laravel: 10.* + testbench: 8.* + - laravel: 9.* + testbench: 7.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + env: + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2745433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +composer.lock + +/vendor/ +/node_modules/ + +.env + +/var/cache/ +.phpunit.result.cache +/coverage/ +/reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5bed16 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] 2023-10-27 +### Created +- Initial setup of this library diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6043954 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when +an individual is representing the project or its community in public spaces. +Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at info@coddin.nl. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d3bd03 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +Thank you for showing interest in contributing to this project. + +**Please submit your pull request against the `main` branch only** + +Please ensure that before you do that you ran `composer checkup` from the project root after you've made any changes. + +We're trying to ensure there is **100%** coverage, including Integration tests for the Repositories, so please ensure any new and or updated tests cover all of your changes. + +Happy contributing! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c30575 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 CoderG33k + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9cbd94 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +A Laravel typed config generator +======================================== +[![Latest Version](http://img.shields.io/packagist/v/MJTheOne/typed-config-generator.svg?style=flat-square)](https://github.com/MJTheOne/typed-config-generator/releases) +![Build](https://github.com/MJTheOne/typed-config-generator/actions/workflows/run-tests.yml/badge.svg?event=push) +[![codecov](https://codecov.io/gh/MJTheOne/typed-config-generator/branch/main/graph/badge.svg?token=BRH4XEU1VK)](https://codecov.io/gh/MJTheOne/typed-config-generator) + +Are you a PHPStan lovin' strict programmer?! Say no more! This package will generate typed config classes for you based on your config files. + +We all struggle with the `mixed` return type of the `config()` helper function. This package will stop your struggle and leave all your (unnecessary?) type checks behind you! + +Installation +============ +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +### Step 1: Download the module +Open a command console, enter your project directory and execute: + +```console +$ composer require coderg33k/typed-config-generator +``` + +### Step 2: Enable the module +*@todo: Autodiscover the ServiceProvider* + +Then, enable the library by adding the service provider to the list of registered providers +in the `config/app.php` file of your project: + +```php +// config/app.php + +'providers' => [ + // ... + CoderG33k\TypedConfigServiceProvider::class, + // ... +]; +``` + +Testing +------- +This bundle uses [PHPUnit](https://phpunit.de) for unit and integration tests. + +It can be run standalone by `composer phpunit` or within the complete checkup by `composer checkup` + +### Checkup +The above-mentioned checkup runs multiple analyses of the bundle's code. This includes [Squizlab's Codesniffer](https://github.com/squizlabs/PHP_CodeSniffer), [PHPStan](https://phpstan.org) and a [coverage check](https://github.com/richardregeer/phpunit-coverage-check). + +Continuous Integration +---------------------- +[GitHub actions](https://github.com/features/actions) are used for continuous integration. Check out the [configuration file](https://github.com/coddin-web/idp-openid-connect-bundle/blob/main/.github/workflows/ci.yml) if you'd like to know more. + +Changelog +--------- +See the [project changelog](https://github.com/coddin-web/idp-openid-connect-bundle/blob/main/CHANGELOG.md) + +Contributing +------------ +Contributions are always welcome. Please see [CONTRIBUTING.md](https://github.com/coddin-web/idp-openid-connect-bundle/blob/main/CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](https://github.com/coddin-web/idp-openid-connect-bundle/blob/main/CODE_OF_CONDUCT.md) for details. + +License +------- +The MIT License (MIT). Please see [License File](https://github.com/coddin-web/idp-openid-connect-bundle/blob/main/LICENSE) for more information. + +Credits +------- +This code is principally developed and maintained by [Marius Posthumus](https://github.com/MJTheOne) diff --git a/assets/laravel.json b/assets/laravel.json new file mode 100644 index 0000000..63e17a6 --- /dev/null +++ b/assets/laravel.json @@ -0,0 +1,6 @@ +[ + { + "package": "laravel/framework", + "config_file": "auth" + } +] diff --git a/assets/spatie.json b/assets/spatie.json new file mode 100644 index 0000000..f77e523 --- /dev/null +++ b/assets/spatie.json @@ -0,0 +1,6 @@ +[ + { + "package": "spatie/laravel-data", + "config_file": "data" + } +] diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..82bd4d9 --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "coderg33k/typed-config-generator", + "description": "Typed Classes for your Laravel configs!", + "type": "library", + "require": { + "php": "^8.1", + "illuminate/support": "^9.0|^10.0" + }, + "require-dev": { + "dg/bypass-finals": "^1.5", + "nunomaduro/larastan": "^2.6", + "orchestra/testbench": "^7.0|^8.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^9.0|^10.4", + "rregeer/phpunit-coverage-check": "^0.3.1", + "slevomat/coding-standard": "^8.14", + "squizlabs/php_codesniffer": "^3.7", + "thecodingmachine/phpstan-strict-rules": "^1.0" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "Coderg33k\\TypedConfigGenerator\\": "src/" + } + }, + "authors": [ + { + "name": "Marius", + "homepage": "https://github.com/MJTheOne" + } + ], + "minimum-stability": "stable", + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/phpcs_codestyle.xml b/phpcs_codestyle.xml new file mode 100644 index 0000000..fcb52b0 --- /dev/null +++ b/phpcs_codestyle.xml @@ -0,0 +1,157 @@ + + + Check for code style. + + + + + + + + + *.js + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + error + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + tests/**/*.php + + + + tests/**/*.php + + + tests/**/*.php + + + + 0 + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..11200d1 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon + + - phpstan/project.neon + - phpstan/laravel.neon + - phpstan/ergebnis.neon + +parameters: + tmpDir: var/cache/phpstan + paths: + - src + + level: 9 + + checkMissingIterableValueType: true + treatPhpDocTypesAsCertain: true + reportMaybesInPropertyPhpDocTypes: false diff --git a/phpstan/ergebnis.neon b/phpstan/ergebnis.neon new file mode 100644 index 0000000..e008dd1 --- /dev/null +++ b/phpstan/ergebnis.neon @@ -0,0 +1,9 @@ +includes: + - ../vendor/ergebnis/phpstan-rules/rules.neon + +parameters: + ergebnis: + noExtends: + classesAllowedToBeExtended: + - Exception + - LogicException diff --git a/phpstan/laravel.neon b/phpstan/laravel.neon new file mode 100644 index 0000000..f1e60d5 --- /dev/null +++ b/phpstan/laravel.neon @@ -0,0 +1,12 @@ +includes: + - ../vendor/nunomaduro/larastan/extension.neon + +parameters: + strictRules: + # strictCalls clashes with larastan and will still throw errors on things larastan fixes + strictCalls: false + + ergebnis: + noExtends: + classesAllowedToBeExtended: + - Tests\TestCase diff --git a/phpstan/project.neon b/phpstan/project.neon new file mode 100644 index 0000000..6f4bb2d --- /dev/null +++ b/phpstan/project.neon @@ -0,0 +1,8 @@ +parameters: + ignoreErrors: + # Add errors to ignore here. + + ergebnis: + noExtends: + classesAllowedToBeExtended: + # Add classes here that are allowed to be extended. diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..369a392 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + ./tests/Unit + + + + + + ./src + + + + + + + + + + + + + + + diff --git a/src/Actions/GetClassForConfig.php b/src/Actions/GetClassForConfig.php new file mode 100644 index 0000000..f129f8f --- /dev/null +++ b/src/Actions/GetClassForConfig.php @@ -0,0 +1,21 @@ + + */ + public static function execute( + string $namespace, + string $config, + ): string { + return $namespace . 'Config\\' . \ucfirst(Str::camel($config)); + } +} diff --git a/src/Actions/GetConfigsForPredeterminedPackage.php b/src/Actions/GetConfigsForPredeterminedPackage.php new file mode 100644 index 0000000..c04a0b3 --- /dev/null +++ b/src/Actions/GetConfigsForPredeterminedPackage.php @@ -0,0 +1,40 @@ + [ + 'app', + 'auth', + 'broadcasting', + 'cache', + 'cors', + 'database', + 'filesystems', + 'hashing', + 'logging', + 'mail', + 'queue', + 'services', + 'session', + 'view', + ], + Package::Spatie => [ + 'cors', + 'csp', + 'data', + 'flare', + 'ignition', + 'permission', + ], + }; + } +} diff --git a/src/Console/Commands/GenerateTypedConfig.php b/src/Console/Commands/GenerateTypedConfig.php new file mode 100644 index 0000000..d5422b1 --- /dev/null +++ b/src/Console/Commands/GenerateTypedConfig.php @@ -0,0 +1,275 @@ +determineWhatShouldBeGenerated(); + + $this->generateTypedClasses(); + } + + private function determineWhatShouldBeGenerated(): void + { + if ($this->option('all')) { + return; + } + + $this->configs = (array) $this->option('config'); + + if (\count($this->configs) === 0) { + $this->promptForConfigs(); + } + } + + private function setupChoices(): array + { + return \array_merge( + ['All configurations'], + \preg_filter('/^/', 'Package: ', Arr::sort(['Laravel', 'Spatie'])), + \preg_filter('/^/', 'Config: ', Arr::sort(\array_keys(config()->all()))), + ); + } + + private function promptForConfigs(): void + { + $choices = $this->setupChoices(); + + $choice = windows_os() + ? select( + label: 'For which config do you want to generate a typed (guestimated) class?', + options: $choices, + scroll: 15, + ) + : search( + label: 'For which config do you want to generate a typed (guestimated) class?', + options: fn ($search) => \array_values(\array_filter( + $choices, + fn ($choice) => \str_contains(\strtolower($choice), \strtolower($search)) + )), + placeholder: 'Search...', + scroll: 15, + ); + + if ($choice == $choices[0] || !\is_string($choice)) { + return; + } + + $this->parseChoice($choice); + } + + private function parseChoice(string $choice): void + { + [$type, $value] = \explode(': ', \strip_tags($choice)); + + switch ($type) { + case 'Config': + $this->configs = [$value]; + break; + case 'Package': + $this->package = $value; + break; + } + } + + private function generateTypedClasses(): void + { + if (\count($this->configs) === 0 && !\is_string($this->package)) { + $this->configs = [\implode(',', \array_keys(config()->all()))]; + } + + if (\is_string($this->package)) { + $this->discoverConfigsForPackage(); + } + + $configsToProcess = \explode(',', $this->configs[0]); + + // Clean up configs. + $configsToProcess = \array_map( + fn (string $config): string => \trim($config), + $configsToProcess, + ); + + $rootNamespace = $this->laravel->getNamespace(); + $namespace = $rootNamespace . self::GENERATED_CONFIG_NAMESPACE_BASE; + + // @todo: Get known namespaces for package configs. + + $nullValues = []; + $properties = []; + foreach ($configsToProcess as $config) { + $configData = config($config); + + foreach ($configData as $key => $value) { + // Let's start guestimating... + if (\is_array($value)) { + $properties[$key] = 'array'; + } else if (\is_bool($value)) { + $properties[$key] = 'bool'; + } else if (\is_float($value)) { + $properties[$key] = 'float'; + } else if (\is_int($value)) { + $properties[$key] = 'int'; + } else if (\is_null($value)) { + $properties[$key] = 'mixed'; + $nullValues[$config][] = $key; + } else if (\is_string($value)) { + $properties[$key] = 'string'; + } else { + $properties[$key] = 'mixed'; + } + } + + $this->buildStub( + config: $config, + namespace: $namespace, + properties: $properties, + ); + } + + if (\count($nullValues) > 0) { + $this->components->warn('Some properties have null values, please check the generated class(es).'); + $this->table( + ['Config', 'Key'], + $this->arrayFlatMap->execute($nullValues), + ); + } + + $this->newLine(); + $this->line('Class(es) created successfully!', 'info'); + } + + private function discoverConfigsForPackage(): void + { + $allConfigs = \array_keys(config()->all()); + + $this->configs = [ + \implode( + ',', + \array_intersect( + $this->getConfigsForPredeterminedPackage->execute( + package: Package::getByValue($this->package), + ), + $allConfigs, + ), + ) + ]; + } + + private function buildStub( + string $config, + string $namespace, + array $properties, + ): void { + $stub = \file_get_contents($this->getStub()); + + $stub = \str_replace( + search: [ + '{{ namespace }}', + '{{ properties }}', + '{{ class }}', + ], + replace: [ + $namespace, + $this->buildProperties($properties), + \ucfirst(Str::camel($config)), + ], + subject: $stub, + ); + + $this->writeClass( + $stub, + $config, + $namespace, + ); + } + + private function getStub(): string + { + $relativePath = '/stubs/typed_config/default.stub'; + + return \file_exists($customPath = $this->laravel->basePath(\trim($relativePath, '/'))) + ? $customPath + : __DIR__ . $relativePath; + } + + private function buildProperties(array $properties): string + { + $properties = \array_map( + fn (string $type, string $name): string => + \sprintf(' public %s $%s,', $type, Str::camel($name)), + $properties, + \array_keys($properties), + ); + + return \implode(PHP_EOL, $properties); + } + + private function writeClass( + string $stub, + string $config, + string $namespace, + ): void { + $classDirectoryPath = \str_replace($this->laravel->getNamespace(), '', $namespace); + + $this->files->makeDirectory( + path: app_path($classDirectoryPath), + recursive: true, + force: true, + ); + + $classPath = app_path( + \sprintf( + '%s/%s.php', + $classDirectoryPath, + \ucfirst(Str::camel($config)), + ) + ); + + $this->files->put($classPath, $stub); + } +} diff --git a/src/Console/Commands/stubs/typed_config/default.stub b/src/Console/Commands/stubs/typed_config/default.stub new file mode 100644 index 0000000..24b0441 --- /dev/null +++ b/src/Console/Commands/stubs/typed_config/default.stub @@ -0,0 +1,13 @@ + self::Laravel, + 'spatie' => self::Spatie, + }; + } +} diff --git a/src/Helper/ArrayFlatMap.php b/src/Helper/ArrayFlatMap.php new file mode 100644 index 0000000..5ea87b7 --- /dev/null +++ b/src/Helper/ArrayFlatMap.php @@ -0,0 +1,25 @@ + $value) { + if (\is_array($value)) { + $result = \array_merge($result, $this->execute($value, $key)); + } else { + $result[] = [$prefix, $value]; + } + } + + return $result; + } +} diff --git a/src/Helper/DoesTypedConfigClassExist.php b/src/Helper/DoesTypedConfigClassExist.php new file mode 100644 index 0000000..c8490ed --- /dev/null +++ b/src/Helper/DoesTypedConfigClassExist.php @@ -0,0 +1,27 @@ + Str::camel($key), + \array_keys($properties), + ), + $properties, + ); + + return new static(...$properties); + } +} diff --git a/src/TypedConfigServiceProvider.php b/src/TypedConfigServiceProvider.php new file mode 100644 index 0000000..16dd1d9 --- /dev/null +++ b/src/TypedConfigServiceProvider.php @@ -0,0 +1,36 @@ +all()) as $config) { + $doesConfigClassExist = DoesTypedConfigClassExist::determine( + namespace: $this->app->getNamespace(), + config: $config, + ); + + if (!$doesConfigClassExist) { + continue; + } + + $class = GetClassForConfig::execute( + namespace: $this->app->getNamespace(), + config: $config, + ); + + $this->app->singleton( + $class, + fn () => $class::fromConfig(...config($config)), + ); + } + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29