Skip to content

Commit b4ff570

Browse files
authored
[symfony] Add NoClassLevelRouteRule (#173)
1 parent 2cbac09 commit b4ff570

File tree

11 files changed

+199
-6
lines changed

11 files changed

+199
-6
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,48 @@ return static function (RoutingConfigurator $routingConfigurator): void {
13251325

13261326
<br>
13271327

1328+
### NoClassLevelRouteRule
1329+
1330+
Avoid class-level route prefixing. Use method route to keep single source of truth and focus
1331+
1332+
```yaml
1333+
rules:
1334+
- Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule
1335+
```
1336+
1337+
```php
1338+
use Symfony\Component\Routing\Attribute\Route;
1339+
1340+
#[Route('/some-prefix')]
1341+
class SomeController
1342+
{
1343+
#[Route('/some-action')]
1344+
public function someAction()
1345+
{
1346+
}
1347+
}
1348+
```
1349+
1350+
:x:
1351+
1352+
<br>
1353+
1354+
```php
1355+
use Symfony\Component\Routing\Attribute\Route;
1356+
1357+
class SomeController
1358+
{
1359+
#[Route('/some-prefix/some-action')]
1360+
public function someAction()
1361+
{
1362+
}
1363+
}
1364+
```
1365+
1366+
:+1:
1367+
1368+
<br>
1369+
13281370
### NoRequiredOutsideClassRule
13291371

13301372
Symfony #[Require]/@required should be used only in classes to avoid misuse

config/symfony-rules.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ rules:
99

1010
# routing
1111
- Symplify\PHPStanRules\Rules\Symfony\NoRoutingPrefixRule
12+
- Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule
1213

1314
# dependency injection
1415
- Symplify\PHPStanRules\Rules\Symfony\NoGetDoctrineInControllerRule

src/Enum/ClassName.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ final class ClassName
99
/**
1010
* @var string
1111
*/
12-
public const ROUTE_ATTRIBUTE = 'Symfony\Component\Routing\Annotation\Route';
12+
public const ROUTE_ATTRIBUTE = 'Symfony\Component\Routing\Attribute\Route';
13+
14+
/**
15+
* @api
16+
* @var string
17+
*/
18+
public const ROUTE_ANNOTATION = 'Symfony\Component\Routing\Annotation\Route';
1319

1420
/**
1521
* @var string

src/Enum/SymfonyRuleIdentifier.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ final class SymfonyRuleIdentifier
3131
public const FORM_TYPE_CLASS_NAME = 'symfony.formTypeClassName';
3232

3333
public const NO_ROUTING_PREFIX = 'symfony.noRoutingPrefix';
34+
35+
public const NO_CLASS_LEVEL_ROUTE = 'symfony.noClassLevelRoute';
3436
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Rules\Symfony;
6+
7+
use PhpParser\Node;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Node\InClassNode;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleError;
12+
use PHPStan\Rules\RuleErrorBuilder;
13+
use Symplify\PHPStanRules\Enum\SymfonyRuleIdentifier;
14+
use Symplify\PHPStanRules\Symfony\NodeAnalyzer\SymfonyControllerAnalyzer;
15+
16+
/**
17+
* @implements Rule<InClassNode>
18+
*
19+
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule\NoClassLevelRouteRuleTest
20+
*/
21+
final class NoClassLevelRouteRule implements Rule
22+
{
23+
/**
24+
* @var string
25+
*/
26+
public const ERROR_MESSAGE = 'Avoid class-level route prefixing. Use method route to keep single source of truth and focus';
27+
28+
public function getNodeType(): string
29+
{
30+
return InClassNode::class;
31+
}
32+
33+
/**
34+
* @param InClassNode $node
35+
* @return RuleError[]
36+
*/
37+
public function processNode(Node $node, Scope $scope): array
38+
{
39+
if (! SymfonyControllerAnalyzer::isControllerScope($scope)) {
40+
return [];
41+
}
42+
43+
$classLike = $node->getOriginalNode();
44+
if (! SymfonyControllerAnalyzer::hasRouteAnnotationOrAttribute($classLike)) {
45+
return [];
46+
}
47+
48+
$ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE)
49+
->line($node->getStartLine())
50+
->identifier(SymfonyRuleIdentifier::NO_CLASS_LEVEL_ROUTE)
51+
->build();
52+
53+
return [$ruleError];
54+
}
55+
}

src/Symfony/NodeAnalyzer/SymfonyControllerAnalyzer.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Symplify\PHPStanRules\Symfony\NodeAnalyzer;
66

77
use PhpParser\Comment\Doc;
8+
use PhpParser\Node\Stmt\ClassLike;
89
use PhpParser\Node\Stmt\ClassMethod;
910
use PHPStan\Analyser\Scope;
1011
use Symplify\PHPStanRules\Enum\ClassName;
@@ -38,17 +39,22 @@ public static function isControllerScope(Scope $scope): bool
3839

3940
public static function isControllerActionMethod(ClassMethod $classMethod): bool
4041
{
41-
$attributeFinder = new AttributeFinder();
42+
return self::hasRouteAnnotationOrAttribute($classMethod);
43+
}
4244

43-
if (! $classMethod->isPublic()) {
45+
public static function hasRouteAnnotationOrAttribute(ClassLike | ClassMethod $node): bool
46+
{
47+
if ($node instanceof ClassMethod && ! $node->isPublic()) {
4448
return false;
4549
}
4650

47-
if ($attributeFinder->hasAttribute($classMethod, ClassName::ROUTE_ATTRIBUTE)) {
51+
$attributeFinder = new AttributeFinder();
52+
53+
if ($attributeFinder->hasAttribute($node, ClassName::ROUTE_ATTRIBUTE)) {
4854
return true;
4955
}
5056

51-
$docComment = $classMethod->getDocComment();
57+
$docComment = $node->getDocComment();
5258
if (! $docComment instanceof Doc) {
5359
return false;
5460
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule\Fixture;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6+
use Symfony\Component\Routing\Attribute\Route;
7+
8+
#[Route('/global-path')]
9+
class ControllerWithClassAttributeRoute extends AbstractController
10+
{
11+
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule\Fixture;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6+
use Symfony\Component\Routing\Annotation\Route;
7+
8+
/**
9+
* @Route("/global-path")
10+
*/
11+
class ControllerWithClassRoute extends AbstractController
12+
{
13+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule\Fixture;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6+
use Symfony\Component\Routing\Attribute\Route;
7+
8+
class SkipControllerWithMethodRoute extends AbstractController
9+
{
10+
#[Route('/global-path')]
11+
public function someMethod()
12+
{
13+
14+
}
15+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule;
4+
5+
use Iterator;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule;
10+
11+
final class NoClassLevelRouteRuleTest extends RuleTestCase
12+
{
13+
/**
14+
* @param string[] $filePaths
15+
*/
16+
#[DataProvider('provideData')]
17+
public function testRule(array $filePaths, array $expectedErrorsWithLines): void
18+
{
19+
$this->analyse($filePaths, $expectedErrorsWithLines);
20+
}
21+
22+
public static function provideData(): Iterator
23+
{
24+
yield [[
25+
__DIR__ . '/Fixture/SkipControllerWithMethodRoute.php',
26+
], []];
27+
28+
yield [[
29+
__DIR__ . '/Fixture/ControllerWithClassAttributeRoute.php',
30+
], [[NoClassLevelRouteRule::ERROR_MESSAGE, 8]]];
31+
32+
yield [[
33+
__DIR__ . '/Fixture/ControllerWithClassRoute.php',
34+
], [[NoClassLevelRouteRule::ERROR_MESSAGE, 11]]];
35+
}
36+
37+
protected function getRule(): Rule
38+
{
39+
return new NoClassLevelRouteRule();
40+
}
41+
}

0 commit comments

Comments
 (0)