diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml deleted file mode 100644 index eb3fb00..0000000 --- a/.github/workflows/coding-standards.yml +++ /dev/null @@ -1,57 +0,0 @@ -on: - pull_request: null - push: - branches: - - master - -name: build - -jobs: - coding-standards: - name: Coding Standards - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - php: ['8.1'] - os: [ubuntu-latest] - - steps: - - name: Set Git To Use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - - - name: Validate Composer - run: composer validate - - - name: Get Composer Cache Directory - # Docs: - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Restore Composer Cache - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer- - - - name: Install Dependencies - uses: nick-invision/retry@v2 - with: - timeout_minutes: 5 - max_attempts: 5 - command: composer update --prefer-dist --no-interaction --no-progress - - - name: Check Coding Standards - run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -vvv --dry-run --using-cache=no diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml new file mode 100644 index 0000000..1684a69 --- /dev/null +++ b/.github/workflows/cs.yml @@ -0,0 +1,16 @@ +on: + pull_request: null + push: + branches: + - '*.*' + +name: coding-standards + +jobs: + psalm: + uses: spiral/gh-actions/.github/workflows/cs.yml@master + with: + os: >- + ['ubuntu-latest'] + php: >- + ['8.1'] diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index fe8102d..e37a09b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -2,7 +2,7 @@ on: pull_request: null push: branches: - - master + - '*.*' name: phpunit diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index e638a3a..e3898f8 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -2,7 +2,7 @@ on: pull_request: null push: branches: - - master + - '*.*' name: static analysis diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 30ad1cb..bb05ab8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,7 +2,7 @@ on: pull_request: null push: branches: - - master + - '*.*' name: build diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 64b369e..40c9b42 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -8,16 +8,8 @@ return (new PhpCsFixer\Config()) ->setRules([ - '@PHP71Migration' => true, - '@PHPUnit75Migration:risky' => true, - '@Symfony' => true, - '@Symfony:risky' => true, - 'protected_to_private' => false, - 'phpdoc_to_comment' => false, - 'single_line_throw' => false, - 'native_constant_invocation' => ['strict' => false], - 'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => false], - 'modernize_strpos' => true, + '@PSR12' => true, + 'ternary_operator_spaces' => false, ]) ->setRiskyAllowed(true) ->setFinder( diff --git a/CHANGELOG.md b/CHANGELOG.md index 27dcb26..9e2a369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog -## 1.0.0 - 2022-01-08 +## Unreleased +- **Bug Fixes** +- **Medium Impact Changes** +- **Other Features** + +## 1.1.0 - 2223-05-14 +- **Other Features** + - Added `Spiral\KnpMenu\Matcher\Voter\RouteVoter`. It is added to the `Knp\Menu\Matcher\Matcher` by default. + +## 1.0.0 - 2023-01-08 - initial release diff --git a/composer.json b/composer.json index 2d972da..effccc8 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,12 @@ "require-dev": { "spiral/framework": "^3.5", "roave/security-advisories": "dev-latest", - "phpunit/phpunit": "^9.5.27", + "phpunit/phpunit": "^10.1", "friendsofphp/php-cs-fixer": "^3.8", - "spiral/testing": "^2.2.0", - "vimeo/psalm": "^4.30", - "spiral/twig-bridge": "^2.0" + "spiral/testing": "^2.3", + "vimeo/psalm": "^5.11", + "spiral/twig-bridge": "^2.0", + "spiral/nyholm-bridge": "^1.3" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 791882d..6f79f42 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,37 +1,31 @@ - + - - - + + + - + + + + + + + + + + ./tests/ - - + src ./tests - + diff --git a/psalm.xml b/psalm.xml index 7b1b42b..12c781d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,8 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + findUnusedBaselineEntry="true" + findUnusedCode="false" > diff --git a/src/Bootloader/KnpMenuBootloader.php b/src/Bootloader/KnpMenuBootloader.php index 8578737..18d4fd5 100644 --- a/src/Bootloader/KnpMenuBootloader.php +++ b/src/Bootloader/KnpMenuBootloader.php @@ -7,6 +7,7 @@ use Knp\Menu\FactoryInterface; use Knp\Menu\Matcher\Matcher; use Knp\Menu\Matcher\MatcherInterface; +use Knp\Menu\Matcher\Voter\VoterInterface; use Knp\Menu\MenuFactory; use Knp\Menu\Provider\MenuProviderInterface; use Knp\Menu\Renderer\PsrProvider; @@ -17,8 +18,10 @@ use Spiral\Boot\DirectoriesInterface; use Spiral\Config\ConfiguratorInterface; use Spiral\Core\Container; +use Spiral\Core\Container\Autowire; use Spiral\KnpMenu\Config\KnpMenuConfig; use Spiral\KnpMenu\Extension\RoutingExtension; +use Spiral\KnpMenu\Matcher\Voter\RouteVoter; use Spiral\KnpMenu\MenuInterface; use Spiral\KnpMenu\MenuRegistry; use Spiral\KnpMenu\Renderer\SpiralRenderer; @@ -34,7 +37,7 @@ final class KnpMenuBootloader extends Bootloader protected const SINGLETONS = [ FactoryInterface::class => MenuFactory::class, MenuFactory::class => MenuFactory::class, - MatcherInterface::class => Matcher::class, + MatcherInterface::class => [self::class, 'initMatcher'], MenuProviderInterface::class => [self::class, 'initMenuProvider'], MenuRegistry::class => MenuProviderInterface::class, RendererProviderInterface::class => [self::class, 'initRenderer'], @@ -71,6 +74,9 @@ private function initConfig(): void 'template' => class_exists(TwigBootloader::class) ? 'knpMenu:knp_menu' : '', 'template_options' => [], 'menus' => [], + 'voters' => [ + RouteVoter::class + ] ] ); } @@ -96,10 +102,7 @@ private function initMenuProvider(KnpMenuConfig $config, Container $container): $registry = new MenuRegistry(); foreach ($config->getMenus() as $name => $menu) { - if (!$menu instanceof MenuInterface) { - $menu = $container->get($menu); - } - + $menu = $this->wire($menu, $container); \assert($menu instanceof MenuInterface); $registry->add(array_is_list($config->getMenus()) ? $menu::class : $name, $menu); @@ -107,4 +110,26 @@ private function initMenuProvider(KnpMenuConfig $config, Container $container): return $registry; } + + private function initMatcher(KnpMenuConfig $config, Container $container): MatcherInterface + { + $voters = []; + foreach ($config->getVoters() as $voter) { + $voter = $this->wire($voter, $container); + \assert($voter instanceof VoterInterface); + + $voters[] = $voter; + } + + return new Matcher($voters); + } + + private function wire(mixed $alias, Container $container): mixed + { + return match (true) { + \is_string($alias) => $container->make($alias), + $alias instanceof Autowire => $alias->resolve($container), + default => $alias + }; + } } diff --git a/src/Config/KnpMenuConfig.php b/src/Config/KnpMenuConfig.php index be141eb..9d7e663 100644 --- a/src/Config/KnpMenuConfig.php +++ b/src/Config/KnpMenuConfig.php @@ -4,10 +4,23 @@ namespace Spiral\KnpMenu\Config; +use Knp\Menu\Matcher\Voter\VoterInterface; use Spiral\Core\Container\Autowire; use Spiral\Core\InjectableConfig; +use Spiral\KnpMenu\Matcher\Voter\RouteVoter; use Spiral\KnpMenu\MenuInterface; +/** + * @psalm-type TMenu = MenuInterface|class-string|Autowire + * @psalm-type TVoter = VoterInterface|class-string|Autowire + * + * @property array{ + * template: non-empty-string, + * template_options: array, + * menus: TMenu[], + * voters: TVoter[] + * } $config + */ final class KnpMenuConfig extends InjectableConfig { public const CONFIG = 'knp-menu'; @@ -16,16 +29,27 @@ final class KnpMenuConfig extends InjectableConfig 'template' => '', 'template_options' => [], 'menus' => [], + 'voters' => [ + RouteVoter::class + ] ]; /** - * @return array + * @return TMenu[] */ public function getMenus(): array { return $this->config['menus']; } + /** + * @return TVoter[] + */ + public function getVoters(): array + { + return $this->config['voters']; + } + /** * @return non-empty-string */ diff --git a/src/Extension/RoutingExtension.php b/src/Extension/RoutingExtension.php index 85dc7cb..9906b77 100644 --- a/src/Extension/RoutingExtension.php +++ b/src/Extension/RoutingExtension.php @@ -21,6 +21,10 @@ public function buildOptions(array $options): array $params = $options['routeParameters'] ?? []; $options['uri'] = (string) $this->router->uri($options['route'], $params); + + // adding the item route to the extras for the RouteVoter + $options['extras']['route'] = $options['route']; + $options['extras']['routeParameters'] = $params; } return $options; diff --git a/src/Matcher/Voter/RouteVoter.php b/src/Matcher/Voter/RouteVoter.php new file mode 100644 index 0000000..42dc982 --- /dev/null +++ b/src/Matcher/Voter/RouteVoter.php @@ -0,0 +1,45 @@ +request->attributes->get(Router::ROUTE_NAME); + $testedRoute = $item->getExtra('route'); + + if (null === $route || null === $testedRoute) { + return null; + } + + if (!\is_string($testedRoute)) { + throw new \InvalidArgumentException('Route extra item must be string.'); + } + + if ($route !== $testedRoute) { + return false; + } + + $parameters = $this->request->attributes->get(Router::ROUTE_MATCHES); + foreach ($item->getExtra('routeParameters', []) as $name => $testedParameter) { + if ((string) $parameters[$name] !== (string) $testedParameter) { + return false; + } + } + + return true; + } +} diff --git a/tests/src/Functional/Bootloader/KnpMenuBootloaderTest.php b/tests/src/Functional/Bootloader/KnpMenuBootloaderTest.php index d5705f1..46b9efa 100644 --- a/tests/src/Functional/Bootloader/KnpMenuBootloaderTest.php +++ b/tests/src/Functional/Bootloader/KnpMenuBootloaderTest.php @@ -14,6 +14,7 @@ use Knp\Menu\Twig\MenuExtension; use Spiral\Boot\DirectoriesInterface; use Spiral\KnpMenu\Config\KnpMenuConfig; +use Spiral\KnpMenu\Matcher\Voter\RouteVoter; use Spiral\KnpMenu\MenuRegistry; use Spiral\KnpMenu\Renderer\SpiralRenderer; use Spiral\KnpMenu\Tests\Functional\TestCase; @@ -77,7 +78,10 @@ public function testDefaultConfigShouldBeDefined(): void $this->assertConfigMatches(KnpMenuConfig::CONFIG, [ 'template' => 'knpMenu:knp_menu', 'template_options' => [], - 'menus' => [] + 'menus' => [], + 'voters' => [ + RouteVoter::class, + ] ]); } diff --git a/tests/src/Functional/TestCase.php b/tests/src/Functional/TestCase.php index e13b40c..704383c 100644 --- a/tests/src/Functional/TestCase.php +++ b/tests/src/Functional/TestCase.php @@ -4,7 +4,9 @@ namespace Spiral\KnpMenu\Tests\Functional; +use Spiral\Bootloader\Http\RouterBootloader; use Spiral\KnpMenu\Bootloader\KnpMenuBootloader; +use Spiral\Nyholm\Bootloader\NyholmBootloader; use Spiral\Twig\Bootloader\TwigBootloader; abstract class TestCase extends \Spiral\Testing\TestCase @@ -19,6 +21,8 @@ public function defineBootloaders(): array return [ TwigBootloader::class, KnpMenuBootloader::class, + RouterBootloader::class, + NyholmBootloader::class, ]; } } diff --git a/tests/src/Unit/Matcher/Voter/RouteVoterTest.php b/tests/src/Unit/Matcher/Voter/RouteVoterTest.php new file mode 100644 index 0000000..ea5d72c --- /dev/null +++ b/tests/src/Unit/Matcher/Voter/RouteVoterTest.php @@ -0,0 +1,195 @@ +createMock(ItemInterface::class); + $item + ->expects($this->once()) + ->method('getExtra') + ->with('route') + ->willReturn(null); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertNull($voter->matchItem($item)); + } + + public function testMatchItemAttributeNull(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->once()) + ->method('getExtra') + ->with('route') + ->willReturn('foo'); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertNull($voter->matchItem($item)); + } + + public function testMatchItemRouteNull(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->once()) + ->method('getExtra') + ->with('route') + ->willReturn(null); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertNull($voter->matchItem($item)); + } + + public function testInvalidRoute(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->once()) + ->method('getExtra') + ->with('route') + ->willReturn(new \stdClass()); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->expectException(\InvalidArgumentException::class); + $voter->matchItem($item); + } + + public function testDifferentRoutes(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->once()) + ->method('getExtra') + ->with('route') + ->willReturn('bar'); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertFalse($voter->matchItem($item)); + } + + public function testSameRoutes(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->exactly(2)) + ->method('getExtra') + ->willReturnOnConsecutiveCalls('foo', []); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertTrue($voter->matchItem($item)); + } + + public function testSameRoutesAndSameParameters(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_MATCHES, ['id' => 1]); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->exactly(2)) + ->method('getExtra') + ->willReturnOnConsecutiveCalls('foo', ['id' => 1]); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertTrue($voter->matchItem($item)); + } + + public function testSameRoutesAndSameParameterAndWrongParameter(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_MATCHES, ['id' => 1, 'invalid' => 2]); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->exactly(2)) + ->method('getExtra') + ->willReturnOnConsecutiveCalls('foo', ['id' => 1, 'invalid' => 3]); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertFalse($voter->matchItem($item)); + } + + public function testSameRoutesAndWrongParameter(): void + { + $serverRequest = new ServerRequest('GET', '/'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_NAME, 'foo'); + $serverRequest = $serverRequest->withAttribute(Router::ROUTE_MATCHES, ['id' => 1]); + $item = $this->createMock(ItemInterface::class); + $item + ->expects($this->exactly(2)) + ->method('getExtra') + ->willReturnOnConsecutiveCalls('foo', ['id' => 3]); + + $container = new Container(); + $container->bind(ServerRequestInterface::class, $serverRequest); + + $request = new InputManager($container); + $voter = new RouteVoter($request); + + $this->assertFalse($voter->matchItem($item)); + } +}