diff --git a/src/Access.php b/src/Access.php new file mode 100644 index 0000000..42ebc44 --- /dev/null +++ b/src/Access.php @@ -0,0 +1,149 @@ +addControl($control); + } + + return $this; + } + + /** + * Add a control to access. + * + * @param Control $control + * + * @return Access + */ + public function addControl(Control $control): self + { + static::$controls[class_basename($control)] = $control; + + return $this; + } + + /** + * Get the control instance for the given model. + * + * @param Model|class-string $model + * + * @return Control|null + */ + public static function controlForModel(Model|string $model): ?Control + { + if (!is_string($model)) { + $model = $model::class; + } + + foreach (static::$controls as $control) { + if ($control->isModel($model)) { + return $control; + } + } + + return null; + } + + /** + * Discover controls for a given path. + * + * @var string[] + */ + public function discoverControls(array $paths): self + { + (new Collection($paths)) + ->flatMap(function ($directory) { + return glob($directory, GLOB_ONLYDIR); + }) + ->reject(function ($directory) { + return !is_dir($directory); + }) + ->each(function ($directory) { + $controls = Finder::create()->files()->in($directory); + + foreach ($controls as $control) { + try { + $control = new ReflectionClass( + static::classFromFile($control, base_path()) + ); + } catch (ReflectionException) { + continue; + } + + if (!$control->isInstantiable()) { + continue; + } + + $this->addControl($control->newInstance()); + } + }); + + return $this; + } + + /** + * Get the control directories that should be used to discover controls. + * + * @return array + */ + public function discoverControlsWithin(): array + { + return static::$controlDiscoveryPaths ?? [ + app()->path('Access/Controls'), + ]; + } + + /** + * Extract the class name from the given file path. + * + * @param SplFileInfo $file + * @param string $basePath + * + * @return string + */ + protected function classFromFile(SplFileInfo $file, string $basePath) + { + $class = trim(Str::replaceFirst($basePath, '', $file->getRealPath()), DIRECTORY_SEPARATOR); + + return ucfirst(Str::camel(str_replace( + [DIRECTORY_SEPARATOR, ucfirst(basename(app()->path())).'\\'], + ['\\', app()->getNamespace()], + ucfirst(Str::replaceLast('.php', '', $class)) + ))); + } +} diff --git a/src/AccessServiceProvider.php b/src/AccessServiceProvider.php index 323b40e..6a7b79f 100644 --- a/src/AccessServiceProvider.php +++ b/src/AccessServiceProvider.php @@ -30,6 +30,8 @@ public function register() __DIR__.'/../config/access-control.php', 'access-control' ); + + $this->registerAccessControls(); } /** @@ -92,6 +94,18 @@ protected function registerStubs() }); } + /** + * Register the default paths controls. + * + * @return void + */ + private function registerAccessControls(): void + { + $access = new Access(); + + $access->discoverControls($access->discoverControlsWithin()); + } + /** * Register the package's publishable resources. * diff --git a/src/Console/ControlMakeCommand.php b/src/Console/ControlMakeCommand.php index f49282f..9a2e641 100644 --- a/src/Console/ControlMakeCommand.php +++ b/src/Console/ControlMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Collection; +use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -11,6 +12,7 @@ use Symfony\Component\Finder\Finder; use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\select; #[AsCommand(name: 'make:control')] class ControlMakeCommand extends GeneratorCommand @@ -41,7 +43,7 @@ class ControlMakeCommand extends GeneratorCommand * * @return string */ - protected function getStub() + protected function getStub(): string { return $this->resolveStubPath('/stubs/control.stub'); } @@ -53,7 +55,7 @@ protected function getStub() * * @return string */ - protected function resolveStubPath($stub) + protected function resolveStubPath($stub): string { return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) ? $customPath @@ -67,7 +69,7 @@ protected function resolveStubPath($stub) * * @return string */ - protected function getDefaultNamespace($rootNamespace) + protected function getDefaultNamespace($rootNamespace): string { return $rootNamespace.'\Access\Controls'; } @@ -79,7 +81,7 @@ protected function getDefaultNamespace($rootNamespace) * * @return string */ - protected function buildClass($name) + protected function buildClass($name): string { $rootNamespace = $this->rootNamespace(); $controlNamespace = $this->getNamespace($name); @@ -90,6 +92,10 @@ protected function buildClass($name) $replace = $this->buildPerimetersReplacements($replace, $this->option('perimeters')); + if ($this->option('model')) { + $replace = $this->buildModelReplacements($replace); + } + if ($baseControlExists) { $replace['use Lomkit\Access\Controls\Control;'] = ''; } else { @@ -103,6 +109,30 @@ protected function buildClass($name) ); } + /** + * Build the model replacement values. + * + * @param array $replace + * + * @return array + */ + protected function buildModelReplacements(array $replace): array + { + $modelClass = $this->parseModel($this->option('model')); + + return array_merge($replace, [ + 'DummyFullModelClass' => $modelClass, + '{{ namespacedModel }}' => $modelClass, + '{{namespacedModel}}' => $modelClass, + 'DummyModelClass' => class_basename($modelClass), + '{{ model }}' => class_basename($modelClass), + '{{model}}' => class_basename($modelClass), + 'DummyModelVariable' => lcfirst(class_basename($modelClass)), + '{{ modelVariable }}' => lcfirst(class_basename($modelClass)), + '{{modelVariable}}' => lcfirst(class_basename($modelClass)), + ]); + } + /** * Build the model replacement values. * @@ -148,6 +178,7 @@ protected function getOptions() { return [ ['perimeters', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The perimeters that the control relies on'], + ['model', 'm', InputOption::VALUE_REQUIRED, 'The model the control relies on'], ]; } @@ -164,13 +195,27 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp if ($this->didReceiveOptions($input)) { return; } - $perimeters = multiselect( - 'What perimeters should this control apply to? (Optional)', - $this->possiblePerimeters(), - ); - if ($perimeters) { - $input->setOption('perimeters', $perimeters); + if (!empty($this->possiblePerimeters())) { + $perimeters = multiselect( + 'What perimeters should this control apply to? (Optional)', + $this->possiblePerimeters(), + ); + + if ($perimeters) { + $input->setOption('perimeters', $perimeters); + } + } + + if (!empty($this->possibleModels())) { + $model = select( + 'What model should this control apply to? (Optional)', + $this->possibleModels(), + ); + + if ($model) { + $input->setOption('model', $model); + } } } @@ -189,4 +234,22 @@ protected function possiblePerimeters() ->values() ->all(); } + + /** + * Get the fully-qualified model class name. + * + * @param string $model + * + * @throws \InvalidArgumentException + * + * @return string + */ + protected function parseModel(string $model): string + { + if (preg_match('([^A-Za-z0-9_/\\\\])', $model)) { + throw new InvalidArgumentException('Model name contains invalid characters.'); + } + + return $this->qualifyModel($model); + } } diff --git a/src/Console/stubs/control.stub b/src/Console/stubs/control.stub index 7cfa2bf..dda0591 100644 --- a/src/Console/stubs/control.stub +++ b/src/Console/stubs/control.stub @@ -9,6 +9,12 @@ use Illuminate\Database\Eloquent\Builder; class {{ class }} extends Control { + /** + * The model the control refers to. + * @var class-string + */ + protected string $model = {{ namespacedModel }}::class; + /** * Retrieve the list of perimeter definitions for the current control. * diff --git a/src/Controls/Control.php b/src/Controls/Control.php index cf37252..2439418 100644 --- a/src/Controls/Control.php +++ b/src/Controls/Control.php @@ -6,26 +6,39 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Str; use Lomkit\Access\Perimeters\Perimeter; use Throwable; class Control { - // @TODO: change readme image /** - * The control name resolver. + * The model the control refers to. * - * @var callable + * @var class-string */ - protected static $controlNameResolver; + protected string $model; /** - * The default namespace where control reside. + * Does the given model match with the current one. * - * @var string + * @param class-string $model + * + * @return bool + */ + public function isModel(string $model): bool + { + return $model === $this->model; + } + + /** + * Return the control current model. + * + * @return string */ - public static $namespace = 'App\\Access\\Controls\\'; + public function getModel(): string + { + return $this->model; + } /** * Retrieve the list of perimeter definitions for the current control. @@ -191,34 +204,6 @@ protected function noResultScoutQuery(\Laravel\Scout\Builder $query): \Laravel\S return $query->where('__NOT_A_VALID_FIELD__', 0); } - /** - * Specify the callback that should be invoked to guess control names. - * - * @param callable(class-string<\Illuminate\Database\Eloquent\Model>): class-string<\Lomkit\Access\Controls\Control> $callback - * - * @return void - */ - public static function guessControlNamesUsing(callable $callback): void - { - static::$controlNameResolver = $callback; - } - - /** - * Get a new control instance for the given model name. - * - * @template TClass of \Illuminate\Database\Eloquent\Model - * - * @param class-string $modelName - * - * @return \Lomkit\Access\Controls\Control - */ - public static function controlForModel(string $modelName): self - { - $control = static::resolveControlName($modelName); - - return $control::new(); - } - /** * Creates a new instance of the control. * @@ -229,37 +214,6 @@ public static function new(): self return new static(); } - /** - * Resolve the control name for a given model. - * - * @template TClass of \Illuminate\Database\Eloquent\Model - * - * @param class-string $modelName The fully qualified model class name. - * - * @return class-string<\Lomkit\Access\Controls\Control> The fully qualified control class name corresponding to the model. - */ - public static function resolveControlName(string $modelName): string - { - // @TODO: The auto guess here is strange, we specify the models / controls everywhere, is there a better way of doing this ? (In policies guess the model as Laravel is doing ?) - // @TODO: Discussed with Lucas G - - if (property_exists($modelName, 'control')) { - return $modelName::control()::class; - } - - $resolver = static::$controlNameResolver ?? function (string $modelName) { - $appNamespace = static::appNamespace(); - - $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') - ? Str::after($modelName, $appNamespace.'Models\\') - : Str::after($modelName, $appNamespace); - - return static::$namespace.$modelName.'Control'; - }; - - return $resolver($modelName); - } - /** * Retrieves the application's namespace. * diff --git a/src/Controls/HasControl.php b/src/Controls/HasControl.php index ee7d64b..ab6a928 100644 --- a/src/Controls/HasControl.php +++ b/src/Controls/HasControl.php @@ -2,6 +2,8 @@ namespace Lomkit\Access\Controls; +use Lomkit\Access\Access; + trait HasControl { /** @@ -14,25 +16,13 @@ public static function bootHasControl() static::addGlobalScope(new HasControlScope()); } - /** - * Retrieves a control instance for the model. - * - * @return Control The control instance for the model. - */ - public static function control() - { - $control = static::newControl() ?? Control::controlForModel(static::class); - - return $control; - } - /** * Attempts to create a new control instance. * * @return Control|null The newly created control instance, or null if creation was unsuccessful. */ - protected static function newControl(): ?Control + protected function newControl(): ?Control { - return property_exists(static::class, 'control') ? static::$control::new() : null; + return Access::controlForModel(static::class); } } diff --git a/src/Controls/HasControlScope.php b/src/Controls/HasControlScope.php index 4d00b37..f01c057 100644 --- a/src/Controls/HasControlScope.php +++ b/src/Controls/HasControlScope.php @@ -54,7 +54,7 @@ protected function addControlled(Builder $builder): void { $builder->macro('controlled', function (Builder $builder) { /** @var Control $control */ - $control = $builder->getModel()::control(); + $control = $builder->getModel()->newControl(); return $control->queried($builder, Auth::user()); }); diff --git a/src/Policies/ControlledPolicy.php b/src/Policies/ControlledPolicy.php index c5c4556..8a32b3f 100644 --- a/src/Policies/ControlledPolicy.php +++ b/src/Policies/ControlledPolicy.php @@ -12,19 +12,9 @@ class ControlledPolicy /** * The model class string. * - * @var string + * @var class-string */ - protected string $model = ''; - - /** - * Returns the fully qualified model class name associated with this policy. - * - * @return string The fully qualified class name of the model. - */ - protected function getModel(): string - { - return $this->model; - } + protected string $control; /** * Retrieves the control instance associated with the current model. @@ -33,7 +23,7 @@ protected function getModel(): string */ protected function getControl(): Control { - return Control::controlForModel($this->getModel()); + return new $this->control(); } /** @@ -45,7 +35,7 @@ protected function getControl(): Control */ public function viewAny(Model $user) { - return $this->getControl()->applies($user, __FUNCTION__, new ($this->getModel())); + return $this->getControl()->applies($user, __FUNCTION__, new ($this->getControl()->getModel())); } /** @@ -70,7 +60,7 @@ public function view(Model $user, Model $model) */ public function create(Model $user) { - return $this->getControl()->applies($user, __FUNCTION__, new ($this->getModel())); + return $this->getControl()->applies($user, __FUNCTION__, new ($this->getControl()->getModel())); } /** diff --git a/tests/Support/Access/Controls/ModelControl.php b/tests/Support/Access/Controls/ModelControl.php index ff48ff2..cdf40ee 100644 --- a/tests/Support/Access/Controls/ModelControl.php +++ b/tests/Support/Access/Controls/ModelControl.php @@ -12,6 +12,8 @@ class ModelControl extends Control { + protected string $model = \Lomkit\Access\Tests\Support\Models\Model::class; + protected function perimeters(): array { return [ diff --git a/tests/Support/Policies/ModelPolicy.php b/tests/Support/Policies/ModelPolicy.php index d45916e..762ad09 100644 --- a/tests/Support/Policies/ModelPolicy.php +++ b/tests/Support/Policies/ModelPolicy.php @@ -3,9 +3,9 @@ namespace Lomkit\Access\Tests\Support\Policies; use Lomkit\Access\Policies\ControlledPolicy; -use Lomkit\Access\Tests\Support\Models\Model; +use Lomkit\Access\Tests\Support\Access\Controls\ModelControl; class ModelPolicy extends ControlledPolicy { - protected string $model = Model::class; + protected string $control = ModelControl::class; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 250dc20..2598bcc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,9 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabaseState; +use Lomkit\Access\Access; use Lomkit\Access\AccessServiceProvider; +use Lomkit\Access\Tests\Support\Access\Controls\ModelControl; use Orchestra\Testbench\TestCase as BaseTestCase; class TestCase extends BaseTestCase @@ -16,6 +18,11 @@ class TestCase extends BaseTestCase protected function setUp(): void { parent::setUp(); + + (new Access()) + ->addControls([ + new ModelControl(), + ]); } /** diff --git a/tests/Unit/Console/MakeCommandsTest.php b/tests/Unit/Console/MakeCommandsTest.php index 0d9e9d4..76f3973 100644 --- a/tests/Unit/Console/MakeCommandsTest.php +++ b/tests/Unit/Console/MakeCommandsTest.php @@ -6,10 +6,21 @@ class MakeCommandsTest extends TestCase { - public function test_make_plain_perimeter_command() + protected function setUp(): void { + parent::setUp(); + + @unlink(app_path('Access/Perimeters/TestPerimeter.php')); + @unlink(app_path('Access/Controls/TestControl.php')); + @unlink(app_path('Access/Controls/Control.php')); @unlink(app_path('Access/Perimeters/TestPerimeter.php')); + @unlink(app_path('Access/Perimeters/SecondTestPerimeter.php')); + @unlink(app_path('Models/User.php')); + @unlink(app_path('Models/Post.php')); + } + public function test_make_plain_perimeter_command() + { $this ->artisan('make:perimeter', ['name' => 'TestPerimeter']) ->assertOk() @@ -23,8 +34,6 @@ public function test_make_plain_perimeter_command() public function test_make_overlay_perimeter_command() { - @unlink(app_path('Access/Perimeters/TestPerimeter.php')); - $this ->artisan('make:perimeter', ['name' => 'TestPerimeter', '--overlay' => true]) ->assertOk() @@ -38,8 +47,6 @@ public function test_make_overlay_perimeter_command() public function test_make_control_command() { - @unlink(app_path('Access/Controls/TestControl.php')); - $this ->artisan('make:control', ['name' => 'TestControl']) ->assertOk() @@ -53,9 +60,6 @@ public function test_make_control_command() public function test_make_control_with_base_control_command() { - @unlink(app_path('Access/Controls/TestControl.php')); - @unlink(app_path('Access/Controls/Control.php')); - file_put_contents(app_path('Access/Controls/Control.php'), ''); $this @@ -72,10 +76,6 @@ public function test_make_control_with_base_control_command() public function test_make_control_with_perimeters_command() { - @unlink(app_path('Access/Controls/TestControl.php')); - @unlink(app_path('Access/Perimeters/TestPerimeter.php')); - @unlink(app_path('Access/Perimeters/SecondTestPerimeter.php')); - file_put_contents(app_path('Access/Perimeters/TestPerimeter.php'), ''); file_put_contents(app_path('Access/Perimeters/SecondTestPerimeter.php'), ''); @@ -94,4 +94,24 @@ public function test_make_control_with_perimeters_command() unlink(app_path('Access/Perimeters/SecondTestPerimeter.php')); unlink(app_path('Access/Controls/TestControl.php')); } + + public function test_make_control_with_model_command() + { + file_put_contents(app_path('Models/User.php'), ''); + file_put_contents(app_path('Models/Post.php'), ''); + + $this + ->artisan('make:control') + ->expectsQuestion('What should the control be named?', 'TestControl') + ->expectsChoice('What model should this control apply to? (Optional)', 'User', ['User', 'Post']) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Controls/TestControl.php')); + $this->assertStringContainsString('class TestControl', file_get_contents(app_path('Access/Controls/TestControl.php'))); + $this->assertStringContainsString('protected string $model = App\Models\User::class;', file_get_contents(app_path('Access/Controls/TestControl.php'))); + + unlink(app_path('Models/User.php')); + unlink(app_path('Models/Post.php')); + } }