diff --git a/README.md b/README.md index f2c63cf..b9fa114 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,24 @@ render for the passed exception class. **Note** Need to provide the full exception class name (FQCN) to the method, it automatically imports it. +#### addMiddlewarePrependToGroup + +Adds middleware to a named group via `prependToGroup` inside the `withMiddleware` closure. +Accepts a single class name or string, or an array of them. Skips values that already exist in the target group. +Does not affect other groups. +Supports `InsertPositionEnum::Start` and `InsertPositionEnum::End` to control the insertion position. `End` is used by default. + +```php +new AppBootstrapBuilder(bootstrap_path('app.php')) + ->addMiddlewarePrependToGroup('api', [ + MyMiddleware::class, + 'throttle:60,1', + ]) + ->addMiddlewarePrependToGroup('web', WebMiddleware::class,, InsertPositionEnum::Start) + ->save(); + +**Note:** Provide the full class name (FQCN) for class-based middleware — the method imports it automatically. + ## Contributing Thank you for considering contributing to Laravel Builder package! The contribution guide diff --git a/src/Builders/AppBootstrapBuilder.php b/src/Builders/AppBootstrapBuilder.php index f3a0316..72b57ab 100644 --- a/src/Builders/AppBootstrapBuilder.php +++ b/src/Builders/AppBootstrapBuilder.php @@ -2,7 +2,10 @@ namespace RonasIT\Larabuilder\Builders; +use Illuminate\Support\Arr; +use RonasIT\Larabuilder\Enums\InsertPositionEnum; use RonasIT\Larabuilder\Visitors\AppBootstrapVisitors\AddExceptionsRender; +use RonasIT\Larabuilder\Visitors\AppBootstrapVisitors\AddMiddlewarePrependToGroup; class AppBootstrapBuilder extends PHPFileBuilder { @@ -25,4 +28,19 @@ public function addExceptionsRender(string $exceptionClass, string $renderBody, return $this; } + + public function addMiddlewarePrependToGroup(string $group, string|array $middleware, InsertPositionEnum $position = InsertPositionEnum::End): self + { + $middlewares = Arr::wrap($middleware); + + $this->traverser->addVisitor(new AddMiddlewarePrependToGroup($group, $middlewares, $position)); + + $imports = array_filter($middlewares, fn ($middleware) => class_exists($middleware)); + + if (!empty($imports)) { + $this->addImports($imports); + } + + return $this; + } } diff --git a/src/Visitors/AppBootstrapVisitors/AbstractAppBootstrapVisitor.php b/src/Visitors/AppBootstrapVisitors/AbstractAppBootstrapVisitor.php index 3c92660..7243ab7 100644 --- a/src/Visitors/AppBootstrapVisitors/AbstractAppBootstrapVisitor.php +++ b/src/Visitors/AppBootstrapVisitors/AbstractAppBootstrapVisitor.php @@ -14,7 +14,7 @@ abstract class AbstractAppBootstrapVisitor extends NodeVisitorAbstract { - protected const FORBIDDEN_NODES = [ + protected const array FORBIDDEN_NODES = [ Class_::class, Trait_::class, Interface_::class, diff --git a/src/Visitors/AppBootstrapVisitors/AddMiddlewarePrependToGroup.php b/src/Visitors/AppBootstrapVisitors/AddMiddlewarePrependToGroup.php new file mode 100644 index 0000000..ea3ca9f --- /dev/null +++ b/src/Visitors/AppBootstrapVisitors/AddMiddlewarePrependToGroup.php @@ -0,0 +1,148 @@ +args[0]->value; + + $this->removeNopPlaceholder($closure); + + $statementIndex = $this->findMiddlewareGroupIndex($closure->stmts); + + if (is_null($statementIndex)) { + $closure->stmts[] = $this->buildPrependToGroupCall(); + } else { + $this->updateMiddlewareGroup($closure, $statementIndex); + } + + return $node; + } + + protected function removeNopPlaceholder(Closure $closure): void + { + if (!empty($closure->stmts) && ($closure->stmts[0] ?? null) instanceof Nop) { + array_shift($closure->stmts); + } + } + + protected function findMiddlewareGroupIndex(array $stmts): ?int + { + return array_find_key($stmts, function (Expression $stmt) { + return !empty($stmt->expr->name) + && $stmt->expr->name->toString() === $this->targetMethod + && $stmt->expr->args[0]->value->value === $this->group; + }); + } + + protected function updateMiddlewareGroup(Closure $closure, int $groupIndex): void + { + $originalMiddlewares = $closure->stmts[$groupIndex]->expr->args[1]->value->value + ?? $closure->stmts[$groupIndex]->expr->args[1]->value->class->name + ?? $closure->stmts[$groupIndex]->expr->args[1]->value->items; + + $originalMiddlewares = is_array($originalMiddlewares) + ? $originalMiddlewares + : [new ArrayItem($closure->stmts[$groupIndex]->expr->args[1]->value)]; + + $mergedMiddlewares = $this->mergeMiddlewares($originalMiddlewares); + + $closure->stmts[$groupIndex]->expr->args[1] = $this->buildMiddlewareArg($mergedMiddlewares); + } + + protected function mergeMiddlewares(array $originalMiddlewareList): array + { + $filteredNewList = []; + + foreach ($this->middlewares as $middleware) { + $sameMiddlewareKey = array_find_key( + $originalMiddlewareList, + fn ($originalMiddleware) => $this->isSameMiddleware($middleware, $originalMiddleware), + ); + + if (is_null($sameMiddlewareKey)) { + $filteredNewList[] = $this->makeArrayItem($middleware); + } + } + + return match ($this->position) { + InsertPositionEnum::Start => [...$filteredNewList, ...$originalMiddlewareList], + InsertPositionEnum::End => [...$originalMiddlewareList, ...$filteredNewList], + }; + } + + protected function isSameMiddleware(string $newMiddleware, ArrayItem $originalMiddleware): bool + { + if ($originalMiddleware->value instanceof ClassConstFetch) { + $originalName = $originalMiddleware->value->class->name; + + return $originalName === $newMiddleware + || $originalName === class_basename($newMiddleware); + } + + return $originalMiddleware->value->value === $newMiddleware; + } + + protected function buildPrependToGroupCall(): Expression + { + $middlewareList = $this->getMiddlewareList(); + + $methodCall = new MethodCall(new Variable('middleware'), new Identifier($this->targetMethod), [ + new Arg(new String_($this->group)), + $this->buildMiddlewareArg($middlewareList), + ]); + + return new Expression($methodCall); + } + + protected function buildMiddlewareArg(array $middlewares): Arg + { + return new Arg(new Array_($middlewares)); + } + + protected function getMiddlewareList(): array + { + return array_map(fn ($middleware) => $this->makeArrayItem($middleware), $this->middlewares); + } + + protected function makeArrayItem(string $middleware): ArrayItem + { + if (class_exists($middleware)) { + $basename = class_basename($middleware); + + $value = new ClassConstFetch(new Name($basename), 'class'); + } else { + $value = new String_($middleware); + } + + return new ArrayItem($value); + } +} diff --git a/tests/AppBootstrapBuilderTest.php b/tests/AppBootstrapBuilderTest.php index 6945e47..8c02c15 100644 --- a/tests/AppBootstrapBuilderTest.php +++ b/tests/AppBootstrapBuilderTest.php @@ -2,11 +2,14 @@ namespace RonasIT\Larabuilder\Tests; +use Illuminate\Auth\Middleware\Authenticate; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\ExpectationFailedException; use RonasIT\Larabuilder\Builders\AppBootstrapBuilder; +use RonasIT\Larabuilder\Enums\InsertPositionEnum; use RonasIT\Larabuilder\Exceptions\InvalidBootstrapAppFileException; use RonasIT\Larabuilder\Exceptions\InvalidPHPCodeException; +use RonasIT\Larabuilder\Tests\Support\Classes\FakeClass; use RonasIT\Larabuilder\Tests\Support\Traits\PHPFileBuilderTestMockTrait; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -137,4 +140,78 @@ public function testInvalidBootstrapAppFileException(string $fixture, string $ty ) ->save(); } + + public function testAddMiddlewarePrependToGroup() + { + $file = $this->generateOriginalStructurePath('bootstrap_empty.php'); + + $this->mockNativeFunction( + 'RonasIT\Larabuilder\Builders', + $this->callFilePutContent($file, 'bootstrap_with_prepend_group.php'), + ); + + new AppBootstrapBuilder($file) + ->addMiddlewarePrependToGroup( + group: 'api', + middleware: FakeClass::class, + ) + ->addMiddlewarePrependToGroup( + group: 'api', + middleware: 'throttle:60,10', + position: InsertPositionEnum::Start, + ) + ->addMiddlewarePrependToGroup( + group: 'web', + middleware: [ + 'throttle:10,10', + FakeClass::class, + ], + ) + ->save(); + } + + public function testAddMiddlewarePrependToGroupExistsMiddlewares() + { + $file = $this->generateOriginalStructurePath('bootstrap_with_prepend_group.php'); + + $this->mockNativeFunction( + 'RonasIT\Larabuilder\Builders', + $this->callFilePutContent($file, 'bootstrap_without_changed_prepend_group.php'), + ); + + new AppBootstrapBuilder($file) + ->addMiddlewarePrependToGroup('api', [ + 'throttle:60,10', + Authenticate::class, + ]) + ->save(); + } + + public static function provideMiddlewareAsString(): array + { + return [ + [ + 'original' => 'bootstrap_with_prepend_group_as_string.php', + 'result' => 'bootstrap_with_prepend_group_as_string.php', + ], + [ + 'original' => 'bootstrap_with_prepend_group_as_string_set_class.php', + 'result' => 'bootstrap_with_prepend_group_as_string_set_class.php', + ], + ]; + } + + #[DataProvider('provideMiddlewareAsString')] + public function testAddMiddlewarePrependToGroupMiddlewareAsString(string $original, string $result): void + { + $file = $this->generateOriginalStructurePath($original); + + $this->mockNativeFunction('RonasIT\Larabuilder\Builders', $this->callFilePutContent($file, $result)); + + new AppBootstrapBuilder($file) + ->addMiddlewarePrependToGroup('api', [ + 'some_middleware', + ]) + ->save(); + } } diff --git a/tests/Support/Classes/FakeClass.php b/tests/Support/Classes/FakeClass.php new file mode 100644 index 0000000..e11cb28 --- /dev/null +++ b/tests/Support/Classes/FakeClass.php @@ -0,0 +1,7 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', ['throttle:60,10', Authenticate::class]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string.php b/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string.php new file mode 100644 index 0000000..b09e30a --- /dev/null +++ b/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', 'throttle:60,10'); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string_set_class.php b/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string_set_class.php new file mode 100644 index 0000000..e334479 --- /dev/null +++ b/tests/Support/OriginStructures/bootstrap_with_prepend_group_as_string_set_class.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', \Illuminate\Auth\Middleware\Authenticate::class); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group.php b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group.php new file mode 100644 index 0000000..9dd5260 --- /dev/null +++ b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group.php @@ -0,0 +1,20 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', ['throttle:60,10', FakeClass::class]); + $middleware->prependToGroup('web', ['throttle:10,10', FakeClass::class]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string.php b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string.php new file mode 100644 index 0000000..bd7306b --- /dev/null +++ b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', ['throttle:60,10', 'some_middleware']); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string_set_class.php b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string_set_class.php new file mode 100644 index 0000000..bed591c --- /dev/null +++ b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_with_prepend_group_as_string_set_class.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', [\Illuminate\Auth\Middleware\Authenticate::class, 'some_middleware']); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/tests/fixtures/AppBootstrapBuilderTest/bootstrap_without_changed_prepend_group.php b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_without_changed_prepend_group.php new file mode 100644 index 0000000..0052a0e --- /dev/null +++ b/tests/fixtures/AppBootstrapBuilderTest/bootstrap_without_changed_prepend_group.php @@ -0,0 +1,19 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prependToGroup('api', ['throttle:60,10', Authenticate::class]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create();