diff --git a/.claude/skills/migrate-to-1.0/SKILL.md b/.claude/skills/migrate-to-1.0/SKILL.md new file mode 100644 index 0000000..4a3906d --- /dev/null +++ b/.claude/skills/migrate-to-1.0/SKILL.md @@ -0,0 +1,203 @@ +--- +name: migrate-to-1.0 +description: Migrate a project consuming ray/web-form-module from 0.x (Doctrine Annotations) to 1.0 (PHP 8 Attributes). Detects @FormValidation / @InputValidation / @VndError annotations, antiCsrf=true options, AuraInputInterceptor Reader arguments, and FormInterface signature mismatches; rewrites them and reports remaining manual steps. +--- + +# Migrate ray/web-form-module 0.x → 1.0 + +Apply this skill in a project that depends on `ray/web-form-module` and is +upgrading from `0.x` to `1.0`. Run it from the project root. + +## Scope + +You will rewrite consumer code only. Do NOT touch files under +`vendor/`. Limit edits to: + +- `src/`, `app/`, `tests/`, `lib/` and similar source roots +- PHP files only (`*.php`) + +Skip generated/cache directories: `var/`, `cache/`, `tmp/`, `build/`, +`node_modules/`, `.git/`. + +## Preflight + +Before changing any file: + +1. Confirm the project's `composer.json` declares `ray/web-form-module`. + If not, abort and tell the user this skill does not apply. +2. Confirm the working tree is clean (`git status`). If not, ask the user + whether to proceed — uncommitted changes will be mixed with this skill's + edits. +3. Bump the constraint to `^1.0` in `composer.json` (`require` section). If + `doctrine/annotations` is in `require` and is no longer used elsewhere, + remove it. + +## Step 1 — Rewrite validation annotations to attributes + +For each PHP file under the source roots: + +### 1a. `@FormValidation` + +Find docblock annotations of the form: + +```php +/** + * @FormValidation(form="contactForm", onFailure="badRequestAction") + */ +public function createAction() +``` + +Rewrite to a native attribute on the method: + +```php +#[FormValidation(form: 'contactForm', onFailure: 'badRequestAction')] +public function createAction() +``` + +Rules: + +- Remove the annotation line from the docblock. If the docblock becomes + empty (only `/**` and `*/`), delete the entire docblock. +- Convert `=` to `:` and double-quoted strings to single-quoted PHP strings. +- Preserve the existing `use Ray\WebFormModule\Annotation\FormValidation;` + import. Add it if missing. + +### 1b. `@FormValidation(... antiCsrf=true)` — **BC break** + +The `antiCsrf` option was removed. Split it into two attributes: + +```php +/** + * @FormValidation(form="contactForm", antiCsrf=true) + */ +``` + +becomes + +```php +#[FormValidation(form: 'contactForm')] +#[CsrfProtection] +``` + +Also add `use Ray\WebFormModule\Annotation\CsrfProtection;`. + +If `antiCsrf=false` (or omitted), drop the option without adding +`#[CsrfProtection]`. The method itself then performs no attribute-driven +CSRF check; the form may still enforce CSRF if it uses `SetAntiCsrfTrait` +(see "Out of scope" below). + +### 1c. `@InputValidation` and `@VndError` + +Same treatment as `@FormValidation` (no `antiCsrf` concern). + +```php +/** @InputValidation(form="form1") */ +public function createAction($name) {} +``` + +```php +/** + * @VndError(message="...", logref="a1000", path="/p", href={"_self"="/p"}) + */ +``` + +become + +```php +#[InputValidation(form: 'form1')] +public function createAction($name) {} +``` + +```php +#[VndError(message: '...', logref: 'a1000', path: '/p', href: ['_self' => '/p'])] +``` + +Note the `{...}` (Doctrine array literal) → `[...]` (PHP array) conversion +in `VndError`'s `href`. + +## Step 2 — Drop `Reader` arguments + +`AuraInputInterceptor`, `InputValidationInterceptor`, and `VndErrorHandler` +no longer accept a `Doctrine\Common\Annotations\Reader`. + +Find direct `new` calls and DI module bindings such as: + +```php +new AuraInputInterceptor($injector, $reader) +new InputValidationInterceptor($injector, $reader, $handler) +new VndErrorHandler($reader) +``` + +Drop the `Reader` argument: + +```php +new AuraInputInterceptor($injector) +new InputValidationInterceptor($injector, $handler) +new VndErrorHandler() +``` + +If the project bound `Doctrine\Common\Annotations\Reader` in a Ray.Di +module solely to satisfy these constructors, remove that binding. + +## Step 3 — Update `FormInterface` implementations + +`FormInterface::input()` and `FormInterface::error()` now declare types: + +```php +public function input(string $input): string; +public function error(string $input): string; +``` + +Any project class implementing `FormInterface` (directly, or via +`AbstractForm` override) must match these signatures. Update: + +```php +public function input($input) +public function error($input) +``` + +to: + +```php +public function input(string $input): string +public function error(string $input): string +``` + +## Step 4 — Update `ValidationException` callers + +`ValidationException::__construct(string $message = '', int $code = 0, +?Throwable $e = null, ?FormValidationError $error = null)` — the third +parameter is now `Throwable|null` (was `Exception|null`) and `$error` is +typed `FormValidationError|null`. + +If the project passes a non-`Throwable` value, fix the call site. + +## Step 5 — Verify + +After all rewrites: + +1. Run the project's static analysis (`composer phpstan`, `composer psalm`, + `composer cs` — whichever exist). +2. Run the project's test suite. +3. `grep -rn "@FormValidation\|@InputValidation\|@VndError\|antiCsrf" src/ tests/ app/ 2>/dev/null` + should return nothing relevant. +4. `grep -rn "Doctrine\\\\Common\\\\Annotations\\\\Reader" src/ app/ 2>/dev/null` + should not show usages tied to this package. + +Report to the user: + +- Files modified (count + paths). +- Any annotation occurrence you could not rewrite automatically (e.g., + uncommon formatting). List file:line so the user can finish manually. +- Static analysis / test results. + +## Out of scope + +- Migrating other libraries' annotations (only `Ray\WebFormModule\Annotation\*`). +- Renaming `SetAntiCsrfTrait` — the trait and `setAntiCsrf()` method are + unchanged in 1.0. +- Deciding whether `#[CsrfProtection]` added by Step 1b is redundant. Forms + that already use `SetAntiCsrfTrait` enforce CSRF via `AbstractForm::apply()` + regardless of the attribute, so the attribute Step 1b inserts is harmless + but unnecessary. Flag these to the user so they can drop the attribute if + they prefer a single declaration site. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b0a252d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..9bd9501 --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,45 @@ +name: Coding Standards + +on: + push: + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + inputs: + php_version: + default: '8.4' + +jobs: + coding-standards: + name: Coding Standards + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php_version || '8.4' }} + tools: cs2pr + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHP_CodeSniffer + run: ./vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..71f9c1f --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,57 @@ +name: Continuous Integration + +on: + push: + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + operating-system: + - ubuntu-latest + php-version: + - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' + - '8.5' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + tools: none + ini-values: assert.exception=1, zend.assertions=1 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php${{ matrix.php-version }}-composer- + ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run test suite + run: ./vendor/bin/phpunit diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..1c3a063 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,130 @@ +name: Static Analysis + +on: + push: + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + workflow_dispatch: + inputs: + php_version: + default: '8.4' + +jobs: + static-analysis-phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php_version || '8.4' }} + tools: cs2pr + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHPStan + run: ./vendor/bin/phpstan analyse -c phpstan.neon --no-progress --no-interaction --error-format=checkstyle | cs2pr + + static-analysis-psalm: + name: Psalm + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php_version || '8.4' }} + tools: cs2pr + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run Psalm + run: ./vendor/bin/psalm --show-info=false --output-format=checkstyle --shepherd | cs2pr + + static-analysis-phpmd: + name: PHPMD + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php_version || '8.4' }} + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHP Mess Detector + run: ./vendor/bin/phpmd src text ./phpmd.xml + + static-analysis-composer-require-checker: + name: ComposerRequireChecker + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php_version || '8.4' }} + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Install dependencies + run: | + composer install --no-interaction --no-progress --prefer-dist + + - name: Run composer-require-checker with config + run: ./vendor/bin/composer-require-checker check ./composer.json --config-file=./composer-require-checker.json diff --git a/.gitignore b/.gitignore index 59370d4..93dec00 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ build vendor/ composer.phar composer.lock +tests/tmp/* +!tests/tmp/.placefolder +.phpunit.result.cache +.phpcs-cache diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..5ea4e95 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD046": { + "style": "fenced" + } +} diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 3518e7e..0000000 --- a/.php_cs +++ /dev/null @@ -1,132 +0,0 @@ -setRiskyAllowed(true) - ->setRules(array( - '@PSR2' => true, - 'header_comment' => ['header' => $header, 'commentType' => 'PHPDoc', 'separate' => 'none'], - 'array_syntax' => ['syntax' => 'short'], - 'binary_operator_spaces' => ['align_equals' => false, 'align_double_arrow' => false], - 'blank_line_after_opening_tag' => true, - 'blank_line_after_namespace' => false, - 'blank_line_before_return' => true, - 'cast_spaces' => true, -// 'class_keyword_remove' => true, - 'combine_consecutive_unsets' => true, - 'concat_space' => ['spacing' => 'one'], - 'declare_equal_normalize' => true, - 'declare_strict_types' => false, - 'dir_constant' => true, - 'ereg_to_preg' => true, - 'function_typehint_space' => true, - 'general_phpdoc_annotation_remove' => true, - 'hash_to_slash_comment' => true, - 'heredoc_to_nowdoc' => true, - 'include' => true, - 'indentation_type' => true, - 'is_null' => ['use_yoda_style' => false], - 'linebreak_after_opening_tag' => true, - 'lowercase_cast' => true, -// 'mb_str_functions' => true, - 'method_separation' => true, - 'modernize_types_casting' => true, - 'native_function_casing' => true, -// 'native_function_invocation' => true, - 'new_with_braces' => false, // - 'no_alias_functions' => true, - 'no_blank_lines_after_class_opening' => true, - 'no_blank_lines_after_phpdoc' => true, - 'no_blank_lines_before_namespace' => true, - 'no_empty_comment' => true, - 'no_empty_phpdoc' => true, - 'no_empty_statement' => true, - 'no_extra_consecutive_blank_lines' => ['break', 'continue', 'curly_brace_block', 'extra', 'parenthesis_brace_block', 'return', 'square_brace_block', 'throw', 'use', 'useTrait'], - 'no_leading_import_slash' => true, - 'no_leading_namespace_whitespace' => true, - 'no_mixed_echo_print' => ['use' => 'echo'], - 'no_multiline_whitespace_around_double_arrow' => true, - 'no_multiline_whitespace_before_semicolons' => true, - 'no_php4_constructor' => false, - 'no_short_bool_cast' => true, - 'no_short_echo_tag' => false, - 'no_singleline_whitespace_before_semicolons' => true, - 'no_spaces_around_offset' => true, - 'no_trailing_comma_in_list_call' => true, - 'no_trailing_comma_in_singleline_array' => true, - 'no_trailing_whitespace' => true, - 'no_trailing_whitespace_in_comment' => true, - 'no_unneeded_control_parentheses' => true, - 'no_unreachable_default_argument_value' => true, - 'no_unused_imports' => true, - 'no_useless_else' => true, - 'no_useless_return' => true, - 'no_whitespace_before_comma_in_array' => true, - 'no_whitespace_in_blank_line' => true, - 'normalize_index_brace' => true, - 'not_operator_with_space' => false, - 'not_operator_with_successor_space' => true, - 'object_operator_without_whitespace' => true, - 'ordered_class_elements' => true, - 'ordered_imports' => true, - 'php_unit_construct' => true, - 'php_unit_dedicate_assert' => true, - 'php_unit_fqcn_annotation' => true, - 'php_unit_strict' => true, -// 'phpdoc_add_missing_param_annotation' => true, - 'phpdoc_align' => true, - 'phpdoc_annotation_without_dot' => true, - 'phpdoc_indent' => true, - 'phpdoc_inline_tag' => true, - 'phpdoc_no_access' => true, - 'phpdoc_no_alias_tag' => ['property-read' => 'property', 'property-write' => 'property', 'type' => 'var'], - 'phpdoc_no_empty_return' => true, - 'phpdoc_no_package' => true, -// 'phpdoc_no_useless_inheritdoc' => true, - 'phpdoc_order' => true, - 'phpdoc_return_self_reference' => true, - 'phpdoc_scalar' => true, - 'phpdoc_separation' => true, - 'phpdoc_single_line_var_spacing' => true, -// 'phpdoc_summary' => true, - 'phpdoc_to_comment' => true, - 'phpdoc_trim' => true, - 'phpdoc_types' => true, - 'phpdoc_var_without_name' => true, - 'pow_to_exponentiation' => true, -// 'pre_increment' => true, - 'protected_to_private' => true, - 'psr0' => true, - 'psr4' => true, - 'random_api_migration' => true, - 'return_type_declaration' => ['space_before' => 'one'], - 'self_accessor' => true, - 'short_scalar_cast' => true, -// 'silenced_deprecation_error' => true, -// 'simplified_null_return' => true, -// 'single_blank_line_before_namespace' => true, - 'single_quote' => true, - 'space_after_semicolon' => true, - 'standardize_not_equals' => true, -// 'strict_comparison' => true, - 'ternary_operator_spaces' => true, - 'strict_param' => true, -// 'ternary_to_null_coalescing' => true, -// 'trailing_comma_in_multiline_array' => true, - 'trim_array_spaces' => true, - 'unary_operator_spaces' => true, - 'whitespace_after_comma_in_array' => true - )) - ->setFinder( - PhpCsFixer\Finder::create() - ->exclude('tests/Fake') - ->exclude('src-data') - ->exclude('src-deprecated') - ->in(__DIR__) - )->setLineEnding("\n") - ->setUsingCache(false); diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 0ca2b93..9c4042c 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,19 +1,12 @@ +build: + nodes: + analysis: + tests: + override: + - php-scrutinizer-run + environment: + php: + version: 8.2 + filter: paths: ["src/*"] -tools: - external_code_coverage: true - php_code_coverage: - timeout: 1200 - php_sim: true - php_mess_detector: true - php_pdepend: true - php_analyzer: true - php_cpd: true - php_mess_detector: - enabled: true - config: - ruleset: ./phpmd.xml - php_code_sniffer: - enabled: true - config: - ruleset: ./phpcs.xml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9c343aa..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: php -sudo: false -php: - - 5.6 - - 7 - - 7.1 - - hhvm -cache: - directories: - - vendor - - $HOME/.composer/cache -env: - matrix: - - DEPENDENCIES="" - - DEPENDENCIES="--prefer-lowest --prefer-stable" -before_script: - - composer self-update - - composer update $DEPENDENCIES -script: - - ./vendor/bin/phpunit --coverage-clover=coverage.clover; - - if [ "$TRAVIS_PHP_VERSION" = "7.1" ]; then wget http://cs.sensiolabs.org/download/php-cs-fixer-v2.phar && php php-cs-fixer-v2.phar fix --config=.php_cs -v --dry-run --using-cache=no --path-mode=intersection `git diff --name-only --diff-filter=ACMRTUXB $TRAVIS_COMMIT_RANGE`; fi - -after_script: - - if [ "$TRAVIS_PHP_VERSION" = "7.1" ]; then wget https://scrutinizer-ci.com/ocular.phar && php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..27a07e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# 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.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.1] - 2026-05-19 + +### Added + +- `Ray\WebFormModule\WebFormModule` — module class whose name matches the + package and namespace. Use this in new code. + +### Deprecated + +- `Ray\WebFormModule\AuraInputModule` is now a thin subclass of + `WebFormModule` kept for backwards compatibility. Existing applications + that install `new AuraInputModule()` continue to work without changes, + but should migrate to `new WebFormModule()`. + +## [1.0.0] - 2026-05-17 + +### Changed + +- Minimum PHP version raised to `8.0`. +- **BC break**: Migrated from Doctrine Annotations to PHP 8 Attributes. All + validation metadata (`@FormValidation`, `@InputValidation`, `@VndError`) is + now expressed with `#[FormValidation]`, `#[InputValidation]`, `#[VndError]`. +- **BC break**: CSRF protection for validation methods is now declared with + the separate `#[CsrfProtection]` attribute. The previous `antiCsrf=true` + boolean option on `@FormValidation` has been removed. CSRF checks are now + opt-in: methods without `#[CsrfProtection]` perform no CSRF verification + even if the form has an `AntiCsrf` object set. + + Before: + + ```php + /** + * @FormValidation(form="contactForm", antiCsrf=true) + */ + public function createAction() {} + ``` + + After: + + ```php + #[FormValidation(form: 'contactForm')] + #[CsrfProtection] + public function createAction() {} + ``` +- **BC break**: `AuraInputInterceptor`, `InputValidationInterceptor` and + `VndErrorHandler` no longer accept a `Doctrine\Common\Annotations\Reader` + in their constructors. Validation attributes are read directly via + `ReflectionMethod::getAttributes()`. +- **BC break**: `FormInterface::input()` and `FormInterface::error()` now declare + parameter and return types (`string $input`, `: string` respectively). + Implementations must update their signatures. +- **BC break**: `ValidationException::__construct()` now declares parameter + types (`string $message`, `int $code`, `Throwable|null $e`, + `FormValidationError|null $error`). The `$error` property is now typed as + `FormValidationError|null` via constructor property promotion. +- Added property type declarations and return types across the codebase to + align with PHP 8 typing. +- Bumped dependencies: `ray/di` `^2.16`, `ray/aop` `^2.14`, + `phpunit/phpunit` `^9.5`. + +### Fixed + +- `Exception\RuntimeException` now correctly extends `\RuntimeException` + instead of `\LogicException`. +- `AntiCsrf::isValid()` uses strict comparison for the CSRF token. +- Eliminated PHP 8.4 deprecation warnings for implicit nullable parameters in + `ValidationException::__construct()` and `VndErrorHandler::makeVndError()`. + +### Added + +- GitHub Actions workflows for tests and coding standards. +- `CHANGELOG.md`. +- `#[CsrfProtection]` attribute for composing CSRF checks with form/input + validation attributes. + +### Removed + +- `doctrine/annotations` dependency. +- Travis CI configuration; replaced with GitHub Actions. + +## [0.6.0] - 2018-05-27 + +See git history for changes prior to 1.0.0. + +[1.0.1]: https://github.com/ray-di/Ray.WebFormModule/compare/1.0.0...1.0.1 +[1.0.0]: https://github.com/ray-di/Ray.WebFormModule/compare/0.6.0...1.0.0 +[0.6.0]: https://github.com/ray-di/Ray.WebFormModule/releases/tag/0.6.0 diff --git a/README.JA.md b/README.JA.md index b3acce9..278b46a 100644 --- a/README.JA.md +++ b/README.JA.md @@ -1,8 +1,8 @@ # Ray.WebFormModule +[![Continuous Integration](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml) +[![Coding Standards](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x) -[![Code Coverage](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/badges/coverage.png?b=1.x)](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x) -[![Build Status](https://travis-ci.org/ray-di/Ray.WebFormModule.svg?branch=1.x)](https://travis-ci.org/ray-di/Ray.WebFormModule) Ray.WebFormModuleはアスペクト指向でフォームのバリデーションを行うモジュールです。 フォームライブラリには[Aura.Input](https://github.com/auraphp/Aura.Input)を使い、 @@ -18,16 +18,19 @@ Ray.WebFormModuleはアスペクト指向でフォームのバリデーション ```php use Ray\Di\AbstractModule; -use Ray\WebFormModule\AuraInputModule; +use Ray\WebFormModule\WebFormModule; class AppModule extends AbstractModule { protected function configure() { - $this->install(new AuraInputModule); + $this->install(new WebFormModule()); } } ``` + +> 互換性のため `Ray\WebFormModule\AuraInputModule` クラスも `WebFormModule` の薄い +> サブクラスとして残されています。新規コードでは `WebFormModule` を使ってください。 ## Usage ### Form @@ -79,7 +82,7 @@ public function createAction($id, $name, $body) ### Controller -コントローラークラスにフォームをインジェクトします。フォームのバリデーションを行うメソッドを`@FormValidation`で +コントローラークラスにフォームをインジェクトします。フォームのバリデーションを行うメソッドを`#[FormValidation]`で アノテートします。この時フォームのプロパティ名を`form`で、バリデーションが失敗したときのメソッドを`onFailure`で指定します。 ```php @@ -95,18 +98,13 @@ class MyController */ protected $contactForm; - /** - * @Inject - * @Named("contact_form") - */ - public function setForm(FormInterface $form) + #[Inject] + public function setForm(#[Named("contact_form")] FormInterface $form) { $this->contactForm = $form; } - /** - * @FormValidation(form="contactForm", onFailure="badRequestAction") - */ + #[FormValidation(form: "contactForm", onFailure: "badRequestAction")] public function createAction() { // validation success @@ -129,31 +127,87 @@ class MyController ### CSRF Protections -CSRF対策を行うためにはフォームにCSRFオブジェクトをセットします。 +CSRF対策は **opt-in** で、独立した 2 つの経路のいずれかで有効化できます。 + +- **フォーム単位**: フォームに `use SetAntiCsrfTrait;` を追加します。 + Ray.Di がトレイトの `#[Inject]` setter 経由で `AntiCsrfInterface` を注入し、 + `postConstruct()` でトークンフィールドが追加され、`apply()` の呼び出しごとに + トークンが検証されます。 +- **アクション単位**: バリデーション対象のコントローラメソッドに + `#[CsrfProtection]` を付与します。`AuraInputInterceptor` が `apply()` 実行前に + `AntiCsrfInterface` をフォームへ注入します。 + +どちらの経路でも、トークン不一致時には `AbstractForm::apply()` が +`CsrfViolationException` を throw します。どちらも使わない場合は CSRF 検証は +行われません。両方を併用しても害はありませんが冗長なので、用途に合わせて +どちらか一方を選んでください。 + +アクション単位 — コントローラのメソッドで宣言: ```php +use Ray\WebFormModule\Annotation\CsrfProtection; +use Ray\WebFormModule\Annotation\FormValidation; + +class MyController +{ + #[FormValidation(form: "contactForm")] + #[CsrfProtection] + public function createAction() + { + } +} +``` + +フォーム単位 — フォーム自身で宣言: + +```php +use Ray\WebFormModule\AbstractForm; use Ray\WebFormModule\SetAntiCsrfTrait; -class MyForm extends AbstractAuraForm +class MyForm extends AbstractForm { use SetAntiCsrfTrait; +} ``` セキュリティレベルを高めるためにはユーザーの認証を含んだカスタムCsrfクラスを作成してフォームクラスにセットします。 詳しくはAura.Inputの[Applying CSRF Protections](https://github.com/auraphp/Aura.Input#applying-csrf-protections)をご覧ください。 +## 0.x からのマイグレーション + +1.0 で Doctrine Annotations を廃止し、PHP 8 Attributes に完全移行しました。 +型宣言も強化されています。主な書き換え: + +| Before (0.x) | After (1.0) | +|--------------------------------------------------------------------|---------------------------------------------------------------------------| +| `@FormValidation(form="f", onFailure="badRequest")` | `#[FormValidation(form: 'f', onFailure: 'badRequest')]` | +| `@FormValidation(form="f", antiCsrf=true)` | `#[FormValidation(form: 'f')]` + `#[CsrfProtection]` | +| `@InputValidation(form="f")` | `#[InputValidation(form: 'f')]` | +| `@VndError(message="...", logref="...")` | `#[VndError(message: '...', logref: '...')]` | +| `new AuraInputInterceptor($injector, $reader)` | `new AuraInputInterceptor($injector)` (`Reader` 引数は不要) | +| `public function input($input)` / `public function error($input)` | `public function input(string $input): string` / `error(string $input): string` | + +破壊的変更の完全なリストは [CHANGELOG.md](CHANGELOG.md) を参照してください。 + +### Claude Code による自動マイグレーション + +リポジトリ同梱の Claude Code skill +[`.claude/skills/migrate-to-1.0/SKILL.md`](.claude/skills/migrate-to-1.0/SKILL.md) +が上記の書き換え (アノテーション → アトリビュート、`antiCsrf=true` の +`#[CsrfProtection]` 分割、`Reader` 引数削除、`FormInterface` 署名更新) を +AI アシスタントに案内します。利用側プロジェクトの `.claude/skills/` に +ディレクトリをコピーして `/migrate-to-1.0` で起動してください。 + ## Validation Exception -`@FormValidation`の代わりに`@InputValidation`とアノテートするとバリデーションが失敗したときに`Ray\WebFormModule\Exception\ValidationException`が投げられるよになります。この場合はHTML表現は使われません。Web APIアプリケーションなどに便利です。 +`#[FormValidation]`の代わりに`#[InputValidation]`とアノテートするとバリデーションが失敗したときに`Ray\WebFormModule\Exception\ValidationException`が投げられるよになります。この場合はHTML表現は使われません。Web APIアプリケーションなどに便利です。 ```php use Ray\WebFormModule\Annotation\InputValidation; class Foo { - /** - * @InputValidation(form="form1") - */ + #[InputValidation(form: "form1")] public function createAction($name) { // ... @@ -168,8 +222,8 @@ class FakeVndErrorModule extends AbstractModule { protected function configure() { - $this->install(new AuraInputModule); - $this->override(new FormVndErrorModule); + $this->install(new WebFormModule()); + $this->override(new FormVndErrorModule()); } ``` @@ -190,17 +244,11 @@ echo $e->error; //} ``` -`@VndError`アノテーションで`vnd.error+json`に必要な情報を加えることができます。 +`#[VndError]`属性で`vnd.error+json`に必要な情報を加えることができます。 ```php - /** - * @FormValidation(form="contactForm") - * @VndError( - * message="foo validation failed", - * logref="a1000", path="/path/to/error", - * href={"_self"="/path/to/error", "help"="/path/to/help"} - * ) - */ + #[FormValidation(form: "contactForm")] + #[VndError(message: "foo validation failed", logref: "a1000", path: "/path/to/error", href: ["_self" => "/path/to/error", "help" => "/path/to/help"])] ``` このオプションのモジュールはAPIアプリケーションの時に有用です。 diff --git a/README.md b/README.md index 2265909..fddc5e8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Ray.WebFormModule +[![Continuous Integration](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/continuous-integration.yml) +[![Coding Standards](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/ray-di/Ray.WebFormModule/actions/workflows/coding-standards.yml) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x) -[![Code Coverage](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/badges/coverage.png?b=1.x)](https://scrutinizer-ci.com/g/ray-di/Ray.WebFormModule/?branch=1.x) -[![Build Status](https://travis-ci.org/ray-di/Ray.WebFormModule.svg?branch=1.x)](https://travis-ci.org/ray-di/Ray.WebFormModule) An aspect oriented web form module powered by [Aura.Input](https://github.com/auraphp/Aura.Input) and [Ray.Di](https://github.com/ray-di/Ray.Di). @@ -12,22 +12,26 @@ An aspect oriented web form module powered by [Aura.Input](https://github.com/au ### Composer install - $ composer require web-form-module + $ composer require ray/web-form-module ### Module install ```php use Ray\Di\AbstractModule; -use Ray\WebFormModule\AuraInputModule; +use Ray\WebFormModule\WebFormModule; class AppModule extends AbstractModule { protected function configure() { - $this->install(new AuraInputModule); + $this->install(new WebFormModule()); } } ``` + +> The legacy `Ray\WebFormModule\AuraInputModule` class is still available as a thin +> subclass of `WebFormModule` for backwards compatibility. New code should prefer +> `WebFormModule`. ## Usage ### Form class @@ -88,7 +92,7 @@ class MyForm extends AbstractForm ``` ### Controller -We annotate the methods which web form validation is required with `@FormValidation`. We can specify form object property name with `name` and failiure method name with `@onFailure`. +We annotate the methods which web form validation is required with `#[FormValidation]`. We can specify form object property name with `form` and failure method name with `onFailure`. ```php use Ray\Di\Di\Inject; @@ -103,21 +107,17 @@ class MyController */ protected $contactForm; - /** - * @Inject - * @Named("contact_form") - */ - public function setForm(FormInterface $form) + #[Inject] + public function setForm(#[Named("contact_form")] FormInterface $form) { $this->contactForm = $form; } - /** - * @FormValidation(form="contactForm", onFailure="badRequestAction") - */ + #[FormValidation(form: "contactForm", onFailure: "badRequestAction")] public function createAction() { // validation success + // More detail for `vnd.error+json` can be added with `#[VndError]`. } public function badRequestAction() @@ -140,17 +140,79 @@ or render input element basis. echo $form->input('name'); // echo $form->error('name'); // "Name must be alphabetic only." or blank. ``` -## CSRF Protections +### CSRF Protections + +CSRF protection is **opt-in** and can be enabled through either of two +independent paths: + +- **Per-form**: add `use SetAntiCsrfTrait;` to the form. `AntiCsrfInterface` + is injected by Ray.Di through the trait's `#[Inject]` setter, the token + field is added in `postConstruct()`, and every `apply()` call verifies + the token. +- **Per-action**: annotate the validated controller method with + `#[CsrfProtection]`. `AuraInputInterceptor` then injects + `AntiCsrfInterface` into the form before `apply()` runs. + +Either path causes `AbstractForm::apply()` to throw `CsrfViolationException` +on token mismatch. Without either path, no CSRF check is performed. Combining +both paths is harmless but redundant — pick whichever fits your use case. + +Per-action — declare CSRF on the controller method: ```php +use Ray\WebFormModule\Annotation\CsrfProtection; +use Ray\WebFormModule\Annotation\FormValidation; + +class MyController +{ + #[FormValidation(form: "contactForm")] + #[CsrfProtection] + public function createAction() + { + } +} +``` + +Per-form — declare CSRF on the form itself: + +```php +use Ray\WebFormModule\AbstractForm; use Ray\WebFormModule\SetAntiCsrfTrait; -class MyController +class MyForm extends AbstractForm { use SetAntiCsrfTrait; +} ``` + You can provide your custom `AntiCsrf` class. See more detail at [Aura.Input](https://github.com/auraphp/Aura.Input#applying-csrf-protections) +## Migration from 0.x + +Version 1.0 drops Doctrine Annotations in favour of native PHP 8 Attributes +and tightens type declarations. The most common rewrites: + +| Before (0.x) | After (1.0) | +|--------------------------------------------------------------------|---------------------------------------------------------------------------| +| `@FormValidation(form="f", onFailure="badRequest")` | `#[FormValidation(form: 'f', onFailure: 'badRequest')]` | +| `@FormValidation(form="f", antiCsrf=true)` | `#[FormValidation(form: 'f')]` + `#[CsrfProtection]` | +| `@InputValidation(form="f")` | `#[InputValidation(form: 'f')]` | +| `@VndError(message="...", logref="...")` | `#[VndError(message: '...', logref: '...')]` | +| `new AuraInputInterceptor($injector, $reader)` | `new AuraInputInterceptor($injector)` (no `Reader` argument) | +| `public function input($input)` / `public function error($input)` | `public function input(string $input): string` / `error(string $input): string` | + +See [CHANGELOG.md](CHANGELOG.md) for the full list of breaking changes. + +### Automated migration with Claude Code + +The repository ships a Claude Code skill at +[`.claude/skills/migrate-to-1.0/SKILL.md`](.claude/skills/migrate-to-1.0/SKILL.md) +that walks an AI assistant through the rewrites above (annotations → +attributes, `antiCsrf=true` split into `#[CsrfProtection]`, `Reader` +argument removal, `FormInterface` signature updates). Copy the directory +into your consuming project's `.claude/skills/` and invoke it via +`/migrate-to-1.0`. + ## Validation Exception When we install `Ray\WebFormModule\FormVndErrorModule` as following, @@ -162,8 +224,8 @@ class FakeVndErrorModule extends AbstractModule { protected function configure() { - $this->install(new AuraInputModule); - $this->override(new FormVndErrorModule); + $this->install(new WebFormModule()); + $this->override(new FormVndErrorModule()); } ``` A `Ray\WebFormModule\Exception\ValidationException` will be thrown. @@ -183,21 +245,15 @@ echo $e->error; //} ``` -More detail for `vnd.error+json`can be add with `@VndError` annotation. +More detail for `vnd.error+json` can be added with the `#[VndError]` attribute. ```php - /** - * @FormValidation(form="contactForm") - * @VndError( - * message="foo validation failed", - * logref="a1000", path="/path/to/error", - * href={"_self"="/path/to/error", "help"="/path/to/help"} - * ) - */ + #[FormValidation(form: "contactForm")] + #[VndError(message: "foo validation failed", logref: "a1000", path: "/path/to/error", href: ["_self" => "/path/to/error", "help" => "/path/to/help"])] ``` This optional module is handy for API application. -### Demo +## Demo $ php -S docs/demo/1.csrf/web.php diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..2c1e5fd --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,13 @@ +{ + "symbol-whitelist": [], + "php-core-extensions": [ + "Core", + "date", + "json", + "pcre", + "Reflection", + "SPL", + "standard" + ], + "scan-files": [] +} diff --git a/composer.json b/composer.json index 3caf3ff..6f8b471 100644 --- a/composer.json +++ b/composer.json @@ -6,14 +6,23 @@ "Ray.Di module" ], "require": { - "ray/di": "^2.5", + "php": ">=8.0.0", + "ray/di": "^2.16", + "ray/aop": "^2.14", "aura/input": "^1.2", "aura/filter": "^2.3|3.x-dev", - "aura/html": "^2.4", - "ray/aura-session-module": "^1.0" + "aura/html": "^2.5", + "aura/session": "^2.1 || ^4.0", + "ray/aura-session-module": "^1.1" }, "require-dev": { - "phpunit/phpunit": "^5.7.13" + "phpunit/phpunit": "^9.5", + "doctrine/coding-standard": "^14.0", + "phpstan/phpstan": "^1.10", + "squizlabs/php_codesniffer": "^4.0", + "vimeo/psalm": "^5.0 || ^6.0", + "phpmd/phpmd": "^2.13", + "maglnet/composer-require-checker": "^4.0" }, "license": "MIT", "autoload":{ @@ -28,8 +37,20 @@ }, "scripts" :{ "test": ["@cs", "phpunit"], + "tests": ["@cs", "@sa", "@test"], "coverage": ["php -dzend_extension=xdebug.so ./vendor/bin/phpunit --coverage-text --coverage-html=build/coverage"], - "cs": ["php-cs-fixer fix -v --dry-run", "phpcs --standard=./phpcs.xml src"], - "cs-fix": ["php-cs-fixer fix -v", "phpcbf src"] + "cs": "phpcs", + "cs-fix": "phpcbf", + "phpstan": "phpstan analyse -c phpstan.neon --no-progress", + "psalm": "psalm --show-info=false", + "phpmd": "phpmd src text ./phpmd.xml", + "crc": "composer-require-checker check ./composer.json --config-file=./composer-require-checker.json", + "sa": ["@phpstan", "@psalm"] + }, + "config": { + "allow-plugins": { + "aura/installer-default": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/docs/demo/1.csrf/ContactForm.php b/docs/demo/1.csrf/ContactForm.php index ce91165..aea6dbd 100644 --- a/docs/demo/1.csrf/ContactForm.php +++ b/docs/demo/1.csrf/ContactForm.php @@ -1,9 +1,4 @@ response['code'] = 201; diff --git a/docs/demo/1.csrf/MyModule.php b/docs/demo/1.csrf/MyModule.php index 295da43..4dd9905 100644 --- a/docs/demo/1.csrf/MyModule.php +++ b/docs/demo/1.csrf/MyModule.php @@ -1,9 +1,4 @@ install(new AuraInputModule); + $this->install(new WebFormModule()); $this->bind(FormInterface::class)->annotatedWith('contact_form')->to(ContactForm::class); } } diff --git a/docs/demo/1.csrf/run.php b/docs/demo/1.csrf/run.php index 81e7c17..9741b75 100644 --- a/docs/demo/1.csrf/run.php +++ b/docs/demo/1.csrf/run.php @@ -1,9 +1,5 @@ addPsr4('Ray\WebFormModule\\', __DIR__); use Aura\Input\Exception\CsrfViolation; diff --git a/docs/demo/1.csrf/web.php b/docs/demo/1.csrf/web.php index 5bf307b..ef72c18 100644 --- a/docs/demo/1.csrf/web.php +++ b/docs/demo/1.csrf/web.php @@ -1,9 +1,5 @@ - - - - - - - - + + + + + + + + + + + + + + + src + tests + */tmp/* + */Fake/* + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..00b655a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +parameters: + level: max + reportUnmatchedIgnoredErrors: false + paths: + - src + ignoreErrors: + # PHPMD annotations (@SuppressWarnings(PHPMD.X)) are not PHPStan tags. + - + identifier: phpDoc.parseError + message: '#@SuppressWarnings#' + # Aura\Html\HelperLocator exposes helpers via __get/__call magic methods. + - + identifier: method.notFound + message: '#Aura\\Html\\HelperLocator::(input|form)#' + path: src/AbstractForm.php + # SetAntiCsrfTrait is part of the public API for consumers to compose. + - + identifier: trait.unused + path: src/SetAntiCsrfTrait.php + # AbstractForm narrows Fieldset::$filter (FilterInterface) to SubjectFilter for typed filter access. + - + identifier: property.phpDocType + message: '#AbstractForm::\$filter#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0c37fb9..ca6b708 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,19 +1,21 @@ - + tests + + + src + + + + + + + - - - + - - - - src - - diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..cd73652 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AbstractForm.php b/src/AbstractForm.php index 3ab127e..4a0431f 100644 --- a/src/AbstractForm.php +++ b/src/AbstractForm.php @@ -1,41 +1,45 @@ >|null */ + protected array|null $errorMessages = null; + protected HelperLocator $helper; + protected AntiCsrfInterface|null $antiCsrf = null; public function __construct() { @@ -48,56 +52,82 @@ public function __clone() } /** - * @param BuilderInterface $builder - * @param FilterFactory $filterFactory - * @param HelperLocatorFactory $helperFactory - * - * @\Ray\Di\Di\Inject + * Return form markup string */ + public function __toString(): string + { + try { + if (! $this instanceof ToStringInterface) { + throw new LogicException(ToStringInterface::class . ' is not implemented'); + } + + return $this->toString(); + } catch (Throwable $e) { + trigger_error($e->getMessage() . PHP_EOL . $e->getTraceAsString(), E_USER_ERROR); + } + + // Reachable when a custom error handler intercepts E_USER_ERROR without halting. + return ''; // @phpstan-ignore deadCode.unreachable + } + + #[Inject] public function setBaseDependencies( BuilderInterface $builder, FilterFactory $filterFactory, - HelperLocatorFactory $helperFactory - ) { + HelperLocatorFactory $helperFactory, + ): void { + assert($builder instanceof Builder); $this->builder = $builder; $this->filter = $filterFactory->newSubjectFilter(); $this->helper = $helperFactory->newInstance(); } - public function setAntiCsrf(AntiCsrfInterface $antiCsrf) + public function setAntiCsrf(AntiCsrfInterface $antiCsrf): void { $this->antiCsrf = $antiCsrf; } - /** - * @\Ray\Di\Di\PostConstruct - */ - public function postConstruct() + public function enableAntiCsrf(AntiCsrfInterface $antiCsrf): void + { + $this->antiCsrf = $antiCsrf; + if (isset($this->inputs[AntiCsrf::TOKEN_KEY])) { + return; + } + + $this->antiCsrf->setField($this); + } + + #[PostConstruct] + public function postConstruct(): void { $this->init(); if ($this->antiCsrf instanceof AntiCsrfInterface) { - $this->antiCsrf->setField($this); + $this->enableAntiCsrf($this->antiCsrf); } } - /** - * {@inheritdoc} - */ - public function input($input) + /** {@inheritDoc} */ + public function input(string $input): string { - return $this->helper->input($this->get($input)); + $inputHtml = $this->helper->input($this->get($input)); + assert(is_string($inputHtml) || $inputHtml instanceof Stringable); + + return (string) $inputHtml; } - /** - * {@inheritdoc} - */ - public function error($input) + /** {@inheritDoc} */ + public function error(string $input): string { - if (! $this->errorMessages) { + if ($this->errorMessages === null) { + /** @var FailureCollection|null $failure */ $failure = $this->filter->getFailures(); - if ($failure) { - $this->errorMessages = $failure->getMessages(); + if ($failure === null) { + return ''; } + + /** @var array> $messages */ + $messages = $failure->getMessages(); + $this->errorMessages = $messages; } if (isset($this->errorMessages[$input])) { @@ -108,18 +138,19 @@ public function error($input) } /** - * @param array $attr attributes for the form tag + * @param array $attr attributes for the form tag * - * @throws \Aura\Html\Exception\HelperNotFound - * @throws \Aura\Input\Exception\NoSuchInput - * - * @return string + * @throws NoSuchInput + * @throws HelperNotFound */ - public function form($attr = []) + public function form(array $attr = []): string { + /** @var string $form */ $form = $this->helper->form($attr); if (isset($this->inputs['__csrf_token'])) { - $form .= $this->helper->input($this->get('__csrf_token')); + /** @var string $input */ + $input = $this->helper->input($this->get('__csrf_token')); + $form .= $input; } return $form; @@ -128,31 +159,36 @@ public function form($attr = []) /** * Applies the filter to a subject. * - * @param array $data + * @param array $data * * @throws CsrfViolationException - * - * @return bool */ - public function apply(array $data) + public function apply(array $data): bool { if ($this->antiCsrf && ! $this->antiCsrf->isValid($data)) { - throw new CsrfViolationException; + throw new CsrfViolationException(); } + $this->fill($data); - $isValid = $this->filter->apply($data); - return $isValid; + return $this->filter->apply($data); } /** * Returns all failure messages for all fields. * - * @return array + * @return array> */ - public function getFailureMessages() + public function getFailureMessages(): array { - $messages = $this->filter->getFailures()->getMessages(); + /** @var FailureCollection|null $failure */ + $failure = $this->filter->getFailures(); + if ($failure === null) { + return []; + } + + /** @var array> $messages */ + $messages = $failure->getMessages(); return $messages; } @@ -160,10 +196,10 @@ public function getFailureMessages() /** * Returns all the fields collection * - * @return \ArrayIterator + * @return ArrayIterator */ - public function getIterator() + public function getIterator(): ArrayIterator { - return new \ArrayIterator($this->inputs); + return new ArrayIterator($this->inputs); } } diff --git a/src/Annotation/AbstractValidation.php b/src/Annotation/AbstractValidation.php index 3cf5e2f..8669808 100644 --- a/src/Annotation/AbstractValidation.php +++ b/src/Annotation/AbstractValidation.php @@ -1,19 +1,12 @@ $href * * @see http://www.w3.org/TR/html5/links.html#link-type-help - * - * about * @see http://tools.ietf.org/html/rfc6903#section-2 - * - * describes * @see http://tools.ietf.org/html/rfc6892 */ - public $path; + public function __construct( + public string $message = '', + public array $href = [], + public string|null $logref = null, + public string|null $path = null, + ) { + } } diff --git a/src/AntiCsrf.php b/src/AntiCsrf.php index 478ec0e..df4f16d 100644 --- a/src/AntiCsrf.php +++ b/src/AntiCsrf.php @@ -1,68 +1,48 @@ session = $session; $this->isCli = is_bool($isCli) ? $isCli : PHP_SAPI === 'cli'; } - public function setField(Fieldset $fieldset) + public function setField(Fieldset $fieldset): void { $fieldset->setField(self::TOKEN_KEY, 'hidden') - ->setAttribs(['value' => $this->getToken()]); + ->setAttribs(['value' => $this->getToken()]); } - /** - * @param array $data - * - * @return bool - */ - public function isValid(array $data) + /** @param array $data */ + public function isValid(array $data): bool { if ($this->isCli) { return true; } - return isset($data[self::TOKEN_KEY]) && $data[self::TOKEN_KEY] == $this->getToken(); + return isset($data[self::TOKEN_KEY]) && $data[self::TOKEN_KEY] === $this->getToken(); } - /** - * @return string - */ - private function getToken() + private function getToken(): string { - $value = $this->isCli ? self::TEST_TOKEN : $this->session->getCsrfToken()->getValue(); - - return $value; + return $this->isCli ? self::TEST_TOKEN : $this->session->getCsrfToken()->getValue(); } } diff --git a/src/AuraInputInterceptor.php b/src/AuraInputInterceptor.php index 48a38d0..676ae74 100644 --- a/src/AuraInputInterceptor.php +++ b/src/AuraInputInterceptor.php @@ -1,56 +1,63 @@ reader = $reader; $this->failureHandler = $handler; } + #[Inject] + public function setAntiCsrf(AntiCsrfInterface $antiCsrf): void + { + $this->antiCsrf = $antiCsrf; + } + /** - * {@inheritdoc} + * {@inheritDoc} + * + * @param MethodInvocation $invocation * * @throws InvalidArgumentException */ public function invoke(MethodInvocation $invocation) { $object = $invocation->getThis(); - /* @var $formValidation FormValidation */ - $formValidation = $this->reader->getMethodAnnotation($invocation->getMethod(), AbstractValidation::class); + $formValidation = $this->getValidationAttribute($invocation->getMethod()); + if ($formValidation === null) { + throw new InvalidArgumentException('The method must be attributed with #[FormValidation] or #[InputValidation]'); + } + $form = $this->getFormProperty($formValidation, $object); + $this->enableCsrfProtection($invocation->getMethod(), $form); $data = $form instanceof SubmitInterface ? $form->submit() : $this->getNamedArguments($invocation); - $isValid = $this->isValid($data, $form); + /** @var array $submit */ + $submit = (array) $data; + $isValid = $this->isValid($submit, $form); if ($isValid === true) { - // validation success return $invocation->proceed(); } @@ -58,28 +65,49 @@ public function invoke(MethodInvocation $invocation) } /** - * @param array $submit - * @param AbstractForm $form + * @param array $submit * * @throws Exception\CsrfViolationException - * - * @return bool */ - public function isValid(array $submit, AbstractForm $form) + public function isValid(array $submit, AbstractForm $form): bool { - $isValid = $form->apply($submit); + return $form->apply($submit); + } - return $isValid; + /** @throws InvalidArgumentException */ + private function enableCsrfProtection(ReflectionMethod $method, AbstractForm $form): void + { + if ($method->getAttributes(CsrfProtection::class) === []) { + return; + } + + if (! $this->antiCsrf instanceof AntiCsrfInterface) { + throw new InvalidArgumentException('#[CsrfProtection] requires AntiCsrfInterface'); + } + + $form->enableAntiCsrf($this->antiCsrf); + } + + private function getValidationAttribute(ReflectionMethod $method): AbstractValidation|null + { + $attributes = $method->getAttributes(AbstractValidation::class, ReflectionAttribute::IS_INSTANCEOF); + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance(); } /** * Return arguments as named arguments. * - * @param MethodInvocation $invocation + * @param MethodInvocation $invocation + * + * @return array * - * @return array + * @SuppressWarnings(PHPMD.Superglobals) */ - private function getNamedArguments(MethodInvocation $invocation) + private function getNamedArguments(MethodInvocation $invocation): array { $submit = []; $params = $invocation->getMethod()->getParameters(); @@ -88,7 +116,7 @@ private function getNamedArguments(MethodInvocation $invocation) $arg = array_shift($args); $submit[$param->getName()] = $arg; } - // has token ? + if (isset($_POST[AntiCsrf::TOKEN_KEY])) { $submit[AntiCsrf::TOKEN_KEY] = $_POST[AntiCsrf::TOKEN_KEY]; } @@ -96,20 +124,13 @@ private function getNamedArguments(MethodInvocation $invocation) return $submit; } - /** - * Return form property - * - * @param AbstractValidation $formValidation - * @param object $object - * - * @return mixed - */ - private function getFormProperty(AbstractValidation $formValidation, $object) + private function getFormProperty(AbstractValidation $formValidation, object $object): AbstractForm { if (! property_exists($object, $formValidation->form)) { throw new InvalidFormPropertyException($formValidation->form); } - $prop = (new \ReflectionClass($object))->getProperty($formValidation->form); + + $prop = (new ReflectionClass($object))->getProperty($formValidation->form); $prop->setAccessible(true); $form = $prop->getValue($object); if (! $form instanceof AbstractForm) { diff --git a/src/AuraInputModule.php b/src/AuraInputModule.php index 738c346..11da61d 100644 --- a/src/AuraInputModule.php +++ b/src/AuraInputModule.php @@ -1,51 +1,16 @@ install(new AuraSessionModule); - $this->bind(Reader::class)->to(AnnotationReader::class)->in(Scope::SINGLETON); - $this->bind(BuilderInterface::class)->to(Builder::class); - $this->bind(FilterInterface::class)->to(Filter::class); - $this->bind(AntiCsrfInterface::class)->to(AntiCsrf::class)->in(Scope::SINGLETON); - $this->bind(FailureHandlerInterface::class)->to(OnFailureMethodHandler::class); - $this->bind(FailureHandlerInterface::class)->annotatedWith('vnd_error')->to(VndErrorHandler::class)->in(Scope::SINGLETON); - $this->bind(HelperLocatorFactory::class); - $this->bind(FilterFactory::class); - $this->bindInterceptor( - $this->matcher->any(), - $this->matcher->annotatedWith(InputValidation::class), - [InputValidationInterceptor::class] - ); - $this->bindInterceptor( - $this->matcher->any(), - $this->matcher->annotatedWith(FormValidation::class), - [AuraInputInterceptor::class] - ); - } } diff --git a/src/Exception/CsrfViolationException.php b/src/Exception/CsrfViolationException.php index c1daeca..3fe1bc2 100644 --- a/src/Exception/CsrfViolationException.php +++ b/src/Exception/CsrfViolationException.php @@ -1,9 +1,7 @@ error = $error; } } diff --git a/src/FailureHandlerInterface.php b/src/FailureHandlerInterface.php index 1da0e22..ce0d3f4 100644 --- a/src/FailureHandlerInterface.php +++ b/src/FailureHandlerInterface.php @@ -1,9 +1,7 @@ $invocation + * + * @return mixed + */ public function handle(AbstractValidation $formValidation, MethodInvocation $invocation, AbstractForm $form); } diff --git a/src/FormFactory.php b/src/FormFactory.php index 8483547..5fcd813 100644 --- a/src/FormFactory.php +++ b/src/FormFactory.php @@ -1,9 +1,7 @@ $class + * @phpstan-param class-string $class */ - public function newInstance($class) + public function newInstance(string $class): AbstractForm { - /** @var $form AbstractForm */ - $form = new $class; - $form->setBaseDependencies(new Builder, new FilterFactory, new HelperLocatorFactory); + $form = new $class(); + $form->setBaseDependencies(new Builder(), new FilterFactory(), new HelperLocatorFactory()); $form->postConstruct(); return $form; diff --git a/src/FormInterface.php b/src/FormInterface.php index 4be67a8..0caf3e8 100644 --- a/src/FormInterface.php +++ b/src/FormInterface.php @@ -1,30 +1,22 @@ $value */ + public function __construct(private array $value) { - $this->value = $value; } - public function __toString() + public function __toString(): string { - return json_encode($this->value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return (string) json_encode($this->value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } } diff --git a/src/FormVndErrorModule.php b/src/FormVndErrorModule.php index b3bbf5d..2db7c3b 100644 --- a/src/FormVndErrorModule.php +++ b/src/FormVndErrorModule.php @@ -1,18 +1,14 @@ bind(FailureHandlerInterface::class)->to(VndErrorHandler::class); diff --git a/src/InputValidationInterceptor.php b/src/InputValidationInterceptor.php index 0e29a57..81f2261 100644 --- a/src/InputValidationInterceptor.php +++ b/src/InputValidationInterceptor.php @@ -1,30 +1,17 @@ reader = $reader; - $this->failureHandler = $handler; + public function __construct( + #[Named('vnd_error')] + FailureHandlerInterface $handler, + ) { + parent::__construct($handler); } } diff --git a/src/OnFailureMethodHandler.php b/src/OnFailureMethodHandler.php index 67c214d..e7aa796 100644 --- a/src/OnFailureMethodHandler.php +++ b/src/OnFailureMethodHandler.php @@ -1,9 +1,7 @@ $invocation */ public function handle(AbstractValidation $formValidation, MethodInvocation $invocation, AbstractForm $form) { @@ -26,11 +30,15 @@ public function handle(AbstractValidation $formValidation, MethodInvocation $inv if (! $formValidation instanceof FormValidation) { throw new InvalidOnFailureMethod(get_class($invocation->getThis())); } + $onFailureMethod = $formValidation->onFailure ?: $invocation->getMethod()->getName() . self::FAILURE_SUFFIX; - if (! $formValidation instanceof FormValidation || ! method_exists($object, $onFailureMethod)) { + if (! method_exists($object, $onFailureMethod)) { throw new InvalidOnFailureMethod(get_class($invocation->getThis())); } - return call_user_func_array([$invocation->getThis(), $onFailureMethod], $args); + /** @var callable $callable */ + $callable = [$object, $onFailureMethod]; + + return call_user_func_array($callable, $args); } } diff --git a/src/SetAntiCsrfTrait.php b/src/SetAntiCsrfTrait.php index 2d7b289..cee30ae 100644 --- a/src/SetAntiCsrfTrait.php +++ b/src/SetAntiCsrfTrait.php @@ -1,21 +1,16 @@ antiCsrf = $antiCsrf; } diff --git a/src/SubmitInterface.php b/src/SubmitInterface.php index 647277d..c9360f7 100644 --- a/src/SubmitInterface.php +++ b/src/SubmitInterface.php @@ -1,9 +1,7 @@ |object */ public function submit(); } diff --git a/src/ToStringInterface.php b/src/ToStringInterface.php new file mode 100644 index 0000000..852af69 --- /dev/null +++ b/src/ToStringInterface.php @@ -0,0 +1,11 @@ +reader = $reader; - } - - /** - * {@inheritdoc} + * {@inheritDoc} + * + * @param MethodInvocation $invocation */ public function handle(AbstractValidation $formValidation, MethodInvocation $invocation, AbstractForm $form) { unset($formValidation); - $vndError = $this->reader->getMethodAnnotation($invocation->getMethod(), VndError::class); + $vndError = $this->getVndErrorAttribute($invocation->getMethod()); $error = new FormValidationError($this->makeVndError($form, $vndError)); - $e = new ValidationException('Validation failed.', 400, null, $error); - throw $e; + throw new ValidationException('Validation failed.', 400, null, $error); + } + + private function getVndErrorAttribute(ReflectionMethod $method): VndError|null + { + $attributes = $method->getAttributes(VndError::class); + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance(); } - private function makeVndError(AbstractForm $form, VndError $vndError = null) + /** + * @return array + * + * @SuppressWarnings(PHPMD.Superglobals) + */ + private function makeVndError(AbstractForm $form, VndError|null $vndError = null): array { $body = ['message' => 'Validation failed']; - $body['path'] = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : ''; + $body['path'] = $_SERVER['PATH_INFO'] ?? ''; $body['validation_messages'] = $form->getFailureMessages(); - $body = $vndError ? $this->optionalAttribute($vndError) + $body : $body; - return $body; + return $vndError ? $this->optionalAttribute($vndError) + $body : $body; } - private function optionalAttribute(VndError $vndError) + /** @return array */ + private function optionalAttribute(VndError $vndError): array { $body = []; if ($vndError->message) { $body['message'] = $vndError->message; } + if ($vndError->path) { $body['path'] = $vndError->path; } + if ($vndError->logref) { $body['logref'] = $vndError->logref; } + if ($vndError->href) { + $body['href'] = $vndError->href; + } + return $body; } } diff --git a/src/WebFormModule.php b/src/WebFormModule.php new file mode 100644 index 0000000..d76106f --- /dev/null +++ b/src/WebFormModule.php @@ -0,0 +1,46 @@ +install(new AuraSessionModule()); + $this->bind(BuilderInterface::class)->to(Builder::class); + $this->bind(FilterInterface::class)->to(Filter::class); + $this->bind(AntiCsrfInterface::class)->to(AntiCsrf::class)->in(Scope::SINGLETON); + $this->bind(FailureHandlerInterface::class)->to(OnFailureMethodHandler::class); + $this->bind(FailureHandlerInterface::class) + ->annotatedWith('vnd_error')->to(VndErrorHandler::class)->in(Scope::SINGLETON); + $this->bind(HelperLocatorFactory::class); + $this->bind(FilterFactory::class); + $this->bindInterceptor( + $this->matcher->any(), + $this->matcher->annotatedWith(InputValidation::class), + [InputValidationInterceptor::class], + ); + $this->bindInterceptor( + $this->matcher->any(), + $this->matcher->annotatedWith(FormValidation::class), + [AuraInputInterceptor::class], + ); + } +} diff --git a/tests/AbstractAuraFormTest.php b/tests/AbstractAuraFormTest.php index ecc9068..5f48111 100644 --- a/tests/AbstractAuraFormTest.php +++ b/tests/AbstractAuraFormTest.php @@ -1,22 +1,26 @@ form = (new FormFactory)->newInstance(FakeForm::class); + + $this->form = (new FormFactory())->newInstance(FakeForm::class); } public function testForm() @@ -27,7 +31,7 @@ public function testForm() public function testAntiCsrfForm() { - $this->form->setAntiCsrf(new FakeAntiCsrf); + $this->form->setAntiCsrf(new FakeAntiCsrf()); $this->form->postConstruct(); $formHtml = $this->form->form(); $this->assertSame('
' . PHP_EOL, $formHtml); @@ -39,7 +43,7 @@ public function testInput() $this->assertSame('' . PHP_EOL, (string) $name); } - public function testError() + public function testError(): string { $this->form->fill([]); $data = ['name' => '@invalid@']; @@ -47,17 +51,27 @@ public function testError() $this->assertFalse($isValid); $error = $this->form->error('name'); $this->assertSame('Name must be alphabetic only.', $error); - $html = (string) $this->form; - return $html; + return (string) $this->form; } - /** - * @depends testError - */ - public function tesetInputDataReamainedOnValidationFailure($html) + /** @depends testError */ + public function testInputDataRemainedOnValidationFailure(string $html): void { $expected = ''; - $this->assertContains($expected, $html); + $this->assertStringContainsString($expected, $html); + } + + public function testNotToStringImplemented() + { + $errNo = $errStr = ''; + set_error_handler(static function (int $no, string $str) use (&$errNo, &$errStr) { + $errNo = $no; + $errStr = $str; + }); + $form = new FakeErrorForm(); + (string) $form; + $this->assertSame(256, $errNo); + restore_error_handler(); } } diff --git a/tests/AbstractFormTest.php b/tests/AbstractFormTest.php index e261ac1..c306eb2 100644 --- a/tests/AbstractFormTest.php +++ b/tests/AbstractFormTest.php @@ -1,9 +1,7 @@ form = (new FormFactory)->newInstance(FakeMiniForm::class); + + $this->form = (new FormFactory())->newInstance(FakeMiniForm::class); } - /** - * @param $method - */ - public function getMethodInvocation(array $arguments) + /** @param array $arguments */ + public function getMethodInvocation(array $arguments): ReflectiveMethodInvocation { // form - $fakeForm = (new FormFactory)->newInstance(FakeMiniForm::class); + $fakeForm = (new FormFactory())->newInstance(FakeMiniForm::class); // controller - $controller = new FakeController; + $controller = new FakeController(); $controller->setForm($fakeForm); // interceptor - $reader = new AnnotationReader; - $interceptor = new AuraInputInterceptor($reader, new VndErrorHandler($reader)); + $interceptor = new AuraInputInterceptor(new VndErrorHandler()); return new ReflectiveMethodInvocation( $controller, - new \ReflectionMethod($controller, 'createAction'), - new Arguments($arguments), - [ - $interceptor - ] + 'createAction', + $arguments, + [$interceptor], ); } @@ -63,7 +58,7 @@ public function testApply() public function testSubmit() { - $this->setExpectedException(ValidationException::class); + $this->expectException(ValidationException::class); $invocation = $this->getMethodInvocation(['na']); $invocation->proceed(); } @@ -78,25 +73,25 @@ public function testErrorReturnEmpty() public function testClone() { $form = clone $this->form; - (new \ReflectionProperty($form, 'filter'))->setAccessible(true); - (new \ReflectionProperty($this->form, 'filter'))->setAccessible(true); + (new ReflectionProperty($form, 'filter'))->setAccessible(true); + (new ReflectionProperty($this->form, 'filter'))->setAccessible(true); $this->assertNotSame(spl_object_hash($form), spl_object_hash($this->form)); } public function testGetItelator() { $itelator = $this->form->getIterator(); - $this->assertInstanceOf(\Iterator::class, $itelator); + $this->assertInstanceOf(Iterator::class, $itelator); } public function testAntiCsrfViolation() { - $this->setExpectedException(CsrfViolationException::class); + $this->expectException(CsrfViolationException::class); $session = new Session( - new SegmentFactory, - new CsrfTokenFactory(new Randval(new Phpfunc)), - new FakePhpfunc, - [] + new SegmentFactory(), + new CsrfTokenFactory(new Randval(new Phpfunc())), + new FakePhpfunc(), + [], ); $this->form->setAntiCsrf(new AntiCsrf($session, false)); $this->form->apply([]); diff --git a/tests/AntiCsrfTest.php b/tests/AntiCsrfTest.php index a20bc94..708c81d 100644 --- a/tests/AntiCsrfTest.php +++ b/tests/AntiCsrfTest.php @@ -1,9 +1,7 @@ phpfunc = new FakePhpfunc; + $this->phpfunc = new FakePhpfunc(); $this->session = $this->newSession(); $this->antiCsrf = new AntiCsrf($this->newSession([]), false, [AntiCsrf::TOKEN_KEY => AntiCsrf::TEST_TOKEN]); } @@ -43,7 +34,7 @@ public function testNew() public function testSetField() { - $result = $this->antiCsrf->setField(new Fieldset(new Builder, new Filter)); + $result = $this->antiCsrf->setField(new Fieldset(new Builder(), new Filter())); $this->assertNull($result); } @@ -53,13 +44,14 @@ public function testIsValid() $this->assertTrue($this->antiCsrf->isValid($data)); } - protected function newSession(array $cookies = []) + /** @param array $cookies */ + protected function newSession(array $cookies = []): Session { return new Session( - new SegmentFactory, + new SegmentFactory(), new CsrfTokenFactory(new Randval(new Phpfunc())), $this->phpfunc, - $cookies + $cookies, ); } } diff --git a/tests/AuraInputInterceptorTest.php b/tests/AuraInputInterceptorTest.php index 400bb63..ff7b065 100644 --- a/tests/AuraInputInterceptorTest.php +++ b/tests/AuraInputInterceptorTest.php @@ -1,142 +1,108 @@ getController($submit); - - return new ReflectiveMethodInvocation( - $object, - new \ReflectionMethod($object, $method), - new Arguments($submit), - [ - new AuraInputInterceptor(new AnnotationReader, $handler) - ] - ); + $this->injector = new Injector(new class () extends AbstractModule { + protected function configure() + { + $this->install(new AuraInputModule()); + $this->bind(FormInterface::class)->annotatedWith('contact_form')->to(FakeForm::class); + $this->bind(FormInterface::class)->annotatedWith('mini_form')->to(FakeMiniForm::class); + } + }); + $this->controller = $this->injector->getInstance(FakeController::class); } - public function getController(array $submit) + public function testProceedFailed() { - $controller = new FakeController; - /** @var $fakeForm FakeForm */ - $fakeForm = (new FormFactory)->newInstance(FakeForm::class); - $fakeForm->setSubmit($submit); - $controller->setForm($fakeForm); - - return $controller; + $result = $this->controller->createAction([]); + $this->assertSame('400', $result); } - public function proceed($controller) + public function testProceed() { - $invocation = new ReflectiveMethodInvocation( - $controller, - new \ReflectionMethod($controller, 'createAction'), - new Arguments([]), - [ - new AuraInputInterceptor(new AnnotationReader, new OnFailureMethodHandler) - ] - ); - $invocation->proceed(); + $result = $this->controller->createAction('BEAR'); + $this->assertSame('201', $result); } - public function testProceedFailed() + public function testCsrfProtectionAttributeEnablesAntiCsrf() { - $invocation = $this->getMethodInvocation('createAction', []); - $result = $invocation->proceed(); - $this->assertSame('400', $result); - } + /** @var FakeCsrfController $controller */ + $controller = $this->injector->getInstance(FakeCsrfController::class); + $this->assertStringNotContainsString(AntiCsrf::TOKEN_KEY, $controller->formHtml()); + + $result = $controller->createAction('BEAR'); - public function testProceed() - { - $invocation = $this->getMethodInvocation('createAction', ['BEAR']); - $result = $invocation->proceed(); $this->assertSame('201', $result); + $this->assertStringContainsString(AntiCsrf::TOKEN_KEY, $controller->formHtml()); } - public function invalidControllerProvider() + public function testInvalidFormPropertyByMissingProperty() { - return [ - [new FakeInvalidController1], - [new FakeInvalidController2] - ]; + $this->expectException(InvalidFormPropertyException::class); + $controller = $this->injector->getInstance(FakeInvalidController1::class); + $controller->createAction(); } - /** - * @dataProvider invalidControllerProvider - * - * @param $controller - */ - public function testInvalidFormPropertyByMissingProperty($controller) + public function testInvalidFormPropertyByMissingProperty2() { - $this->setExpectedException(InvalidFormPropertyException::class); - $this->proceed($controller); + $this->expectException(InvalidFormPropertyException::class); + $controller = $this->injector->getInstance(FakeInvalidController2::class); + $controller->createAction(); } public function testInvalidFormPropertyException() { - $this->setExpectedException(InvalidOnFailureMethod::class); - $controller = new FakeInvalidController3; - /** @var $fakeForm FakeForm */ - $fakeForm = (new FormFactory)->newInstance(FakeForm::class); - $fakeForm->setSubmit(['name' => '']); - $controller->setForm($fakeForm); - $this->proceed($controller); + $this->expectException(InvalidFormPropertyException::class); + /** @var FakeInvalidController3 $controller */ + $controller = $this->injector->getInstance(FakeInvalidController3::class); + $controller->createAction(''); } public function testInvalidFormPropertyByInvalidInstance() { - $this->setExpectedException(InvalidFormPropertyException::class); - $object = new FakeInvalidController1; - $invocation = new ReflectiveMethodInvocation( - $object, - new \ReflectionMethod($object, 'createAction'), - new Arguments(['name' => '']), - [ - new AuraInputInterceptor(new AnnotationReader, new OnFailureMethodHandler) - ] - ); - $invocation->proceed(); + $this->expectException(InvalidFormPropertyException::class); + $controller = $this->injector->getInstance(FakeInvalidController1::class); + $controller->createAction(''); } public function testProceedWithVndErrorHandler() { + /** @var FakeControllerVndError $controller */ + $controller = $this->injector->getInstance(FakeControllerVndError::class); try { - $invocation = $this->getMethodInvocation('createAction', [], new VndErrorHandler(new AnnotationReader)); - $invocation->proceed(); + $controller->createAction(''); + $this->fail('ValidationException should be thrown'); } catch (ValidationException $e) { $this->assertInstanceOf(FormValidationError::class, $e->error); $json = (string) $e->error; $this->assertSame('{ - "message": "Validation failed", - "path": "", + "message": "foo validation failed", + "path": "/path/to/error", + "logref": "a1000", + "href": { + "_self": "/path/to/error", + "help": "/path/to/help" + }, "validation_messages": { "name": [ "Name must be alphabetic only." diff --git a/tests/AuraInputModuleTest.php b/tests/AuraInputModuleTest.php index 97ff88d..d08deed 100644 --- a/tests/AuraInputModuleTest.php +++ b/tests/AuraInputModuleTest.php @@ -1,43 +1,28 @@ getInstance(FakeController::class); $this->assertInstanceOf(WeavedInterface::class, $controller); } public function testExceptionOnFailure() { - $this->setExpectedException(ValidationException::class); - $injector = new Injector(new FakeModule, __DIR__ . '/tmp'); - /** @var $controller FakeInputValidationController */ + $this->expectException(ValidationException::class); + $injector = new Injector(new FakeModule(), __DIR__ . '/tmp'); + /** @var FakeInputValidationController $controller */ $controller = $injector->getInstance(FakeInputValidationController::class); $controller->createAction(''); } diff --git a/tests/Fake/FakeController.php b/tests/Fake/FakeController.php index d642e52..f452680 100644 --- a/tests/Fake/FakeController.php +++ b/tests/Fake/FakeController.php @@ -13,20 +13,13 @@ class FakeController */ protected $form; - /** - * @Inject - * @Named("contact_form") - */ - public function setForm(FormInterface $form) + #[Inject] + public function setForm(#[Named('contact_form')] FormInterface $form) { $this->form = $form; } - /** - * @FormValidation - * - * = is same as @ FormValidation(form="form", onFailure="createActionValidationFailed") - */ + #[FormValidation] public function createAction($name) { return '201'; diff --git a/tests/Fake/FakeControllerVndError.php b/tests/Fake/FakeControllerVndError.php index 3f44ab5..6885cd4 100644 --- a/tests/Fake/FakeControllerVndError.php +++ b/tests/Fake/FakeControllerVndError.php @@ -14,24 +14,14 @@ class FakeControllerVndError */ protected $form1; - /** - * @Inject - * @Named("contact_form") - */ - public function setForm(FormInterface $form) + #[Inject] + public function setForm(#[Named('contact_form')] FormInterface $form) { $this->form1 = $form; } - /** - * @InputValidation(form="form1") - * @VndError( - * message="foo validation failed", - * logref="a1000", - * path="/path/to/error", - * href={"_self"="/path/to/error", "help"="/path/to/help"} - * ) - */ + #[InputValidation(form: 'form1')] + #[VndError(message: 'foo validation failed', href: ['_self' => '/path/to/error', 'help' => '/path/to/help'], logref: 'a1000', path: '/path/to/error')] public function createAction($name) { } diff --git a/tests/Fake/FakeCsrfController.php b/tests/Fake/FakeCsrfController.php new file mode 100644 index 0000000..dbd3962 --- /dev/null +++ b/tests/Fake/FakeCsrfController.php @@ -0,0 +1,37 @@ +form = $form; + } + + #[FormValidation] + #[CsrfProtection] + public function createAction($name) + { + return '201'; + } + + public function formHtml() : string + { + assert($this->form instanceof AbstractForm); + + return $this->form->form(); + } +} diff --git a/tests/Fake/FakeErrorForm.php b/tests/Fake/FakeErrorForm.php new file mode 100644 index 0000000..f8a9521 --- /dev/null +++ b/tests/Fake/FakeErrorForm.php @@ -0,0 +1,13 @@ +form = $form; } - /** - * @InputValidation - */ + #[InputValidation] public function createAction($name) { } diff --git a/tests/Fake/FakeInvalidController1.php b/tests/Fake/FakeInvalidController1.php index 32aa3dc..122fb70 100644 --- a/tests/Fake/FakeInvalidController1.php +++ b/tests/Fake/FakeInvalidController1.php @@ -6,9 +6,7 @@ class FakeInvalidController1 { - /** - * @FormValidation(form="missing") - */ + #[FormValidation(form: "missing")] public function createAction() { } diff --git a/tests/Fake/FakeInvalidController2.php b/tests/Fake/FakeInvalidController2.php index 9f6bb47..711449c 100644 --- a/tests/Fake/FakeInvalidController2.php +++ b/tests/Fake/FakeInvalidController2.php @@ -8,9 +8,7 @@ class FakeInvalidController2 { private $form = null; - /** - * @FormValidation - */ + #[FormValidation] public function createAction() { } diff --git a/tests/Fake/FakeInvalidController3.php b/tests/Fake/FakeInvalidController3.php index 8322619..be70fac 100644 --- a/tests/Fake/FakeInvalidController3.php +++ b/tests/Fake/FakeInvalidController3.php @@ -13,9 +13,7 @@ public function setForm(FormInterface $form) $this->form = $form; } - /** - * @FormValidation(onFailure="missing_method") - */ + #[FormValidation(onFailure: "missing_method")] public function createAction($name) { } diff --git a/tests/Fake/FakeInvalidInstanceController.php b/tests/Fake/FakeInvalidInstanceController.php index d64b9c6..5f93b9d 100644 --- a/tests/Fake/FakeInvalidInstanceController.php +++ b/tests/Fake/FakeInvalidInstanceController.php @@ -8,9 +8,7 @@ class FakeInvalidInstanceController { private $form; - /** - * @FormValidation - */ + #[FormValidation] public function createAction() { } diff --git a/tests/Fake/FakeModule.php b/tests/Fake/FakeModule.php index 2e51456..f800aa6 100644 --- a/tests/Fake/FakeModule.php +++ b/tests/Fake/FakeModule.php @@ -10,7 +10,7 @@ class FakeModule extends AbstractModule protected function configure() { $this->bind(Phpfunc::class)->to(FakePhpFunc::class); - $this->install(new AuraInputModule); + $this->install(new WebFormModule()); $this->bind(FormInterface::class)->annotatedWith('contact_form')->to(FakeForm::class); } } diff --git a/tests/Fake/FakeNameForm.php b/tests/Fake/FakeNameForm.php index b661870..92dbee7 100644 --- a/tests/Fake/FakeNameForm.php +++ b/tests/Fake/FakeNameForm.php @@ -4,7 +4,7 @@ use Aura\Html\Helper\Tag; -class FakeNameForm extends AbstractForm +class FakeNameForm extends AbstractForm implements ToStringInterface { /** * {@inheritdoc} @@ -41,7 +41,7 @@ public function submit() /** * {@inheritdoc} */ - public function __toString() + public function toString() : string { $form = $this->form([ 'method' => 'post', diff --git a/tests/FormFactoryTest.php b/tests/FormFactoryTest.php index 07451e3..59d6f2a 100644 --- a/tests/FormFactoryTest.php +++ b/tests/FormFactoryTest.php @@ -1,22 +1,21 @@ factory = new FormFactory; + + $this->factory = new FormFactory(); } public function testNewInstance() diff --git a/tests/VndErrorHandlerTest.php b/tests/VndErrorHandlerTest.php index 80519d7..4db8b3b 100644 --- a/tests/VndErrorHandlerTest.php +++ b/tests/VndErrorHandlerTest.php @@ -1,30 +1,28 @@ controller = (new Injector(new FakeVndErrorModule, __DIR__ . '/tmp'))->getInstance(FakeController::class); + + $this->controller = (new Injector(new FakeVndErrorModule(), __DIR__ . '/tmp'))->getInstance(FakeController::class); } public function testValidationException() { - $this->setExpectedException(ValidationException::class); + $this->expectException(ValidationException::class); $this->controller->createAction(''); } @@ -48,8 +46,8 @@ public function testValidationExceptionError() public function testVndErrorAnnotation() { - /** @var $controller FakeControllerVndError */ - $controller = (new Injector(new FakeVndErrorModule))->getInstance(FakeControllerVndError::class); + /** @var FakeControllerVndError $controller */ + $controller = (new Injector(new FakeVndErrorModule()))->getInstance(FakeControllerVndError::class); try { $controller->createAction(''); } catch (ValidationException $e) { @@ -58,6 +56,10 @@ public function testVndErrorAnnotation() "message": "foo validation failed", "path": "/path/to/error", "logref": "a1000", + "href": { + "_self": "/path/to/error", + "help": "/path/to/help" + }, "validation_messages": { "name": [ "Name must be alphabetic only." diff --git a/tests/WebFormModuleTest.php b/tests/WebFormModuleTest.php new file mode 100644 index 0000000..6254037 --- /dev/null +++ b/tests/WebFormModuleTest.php @@ -0,0 +1,47 @@ +install(new WebFormModule()); + $this->bind(FormInterface::class)->annotatedWith('contact_form')->to(FakeForm::class); + } + }, __DIR__ . '/tmp'); + $controller = $injector->getInstance(FakeController::class); + $this->assertInstanceOf(WeavedInterface::class, $controller); + } + + public function testAuraInputModuleIsAliasOfWebFormModule() + { + $this->assertInstanceOf(WebFormModule::class, new AuraInputModule()); + } + + public function testExceptionOnFailure() + { + $this->expectException(ValidationException::class); + $injector = new Injector(new class () extends AbstractModule { + protected function configure() + { + $this->install(new WebFormModule()); + $this->bind(FormInterface::class)->annotatedWith('contact_form')->to(FakeForm::class); + } + }, __DIR__ . '/tmp'); + /** @var FakeInputValidationController $controller */ + $controller = $injector->getInstance(FakeInputValidationController::class); + $controller->createAction(''); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ba7dfce..ab50824 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,11 +1,17 @@