diff --git a/composer.json b/composer.json index 59701e78f..c531237c7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "require": { "php": "^7.4", "ext-json": "*", - "illuminate/support": "^6.0|^7.0" + "illuminate/support": "^6.0|^7.0", + "doctrine/dbal": "^2.10" }, "require-dev": { "mockery/mockery": "^1.3", diff --git a/config/config.php b/config/config.php index 60c24a033..966bd7a06 100644 --- a/config/config.php +++ b/config/config.php @@ -41,7 +41,7 @@ | */ - 'base' => '/restify-api', + 'base' => '/api/restify', /* |-------------------------------------------------------------------------- diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..ad975d9af --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests + + + + + src/ + + src/Commands + + + + + + + + + + + + + + + diff --git a/routes/api.php b/routes/api.php index 1c4808627..2abec02b6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,14 @@ - */ class PolicyCommand extends GeneratorCommand { - /** - * The console command name. - * - * @var string - */ protected $name = 'restify:policy'; - /** - * The console command description. - * - * @var string - */ protected $description = 'Create a new policy for a specific model.'; - /** - * The type of class being generated. - * - * @var string - */ - protected $type = 'Policy'; - - /** - * Execute the console command. - * - * @return bool|null - * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException - */ public function handle() { - parent::handle(); + if (parent::handle() === false && ! $this->option('force')) { + return false; + } } - /** - * Build the class with the given name. - * - * @param string $name - * @return string - * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException - */ protected function buildClass($name) { - $namespacedModel = null; - $model = $this->option('model'); + $class = $this->replaceModel(parent::buildClass($name)); - if (is_null($model)) { - $model = $this->argument('name'); - } + $class = $this->replaceQualifiedModel($class); - if ($model && ! Str::startsWith($model, [$this->laravel->getNamespace(), '\\'])) { - $namespacedModel = $this->laravel->getNamespace().$model; - } + return $class; + } - $name .= 'Policy'; + protected function replaceClass($stub, $name) + { + $class = str_replace($this->getNamespace($name).'\\', '', $this->guessPolicyName()); - $rendered = str_replace( - 'UseDummyModel', $namespacedModel ?? $model, parent::buildClass($name) - ); + return str_replace(['{{ class }}', '{{class}}'], $class, $stub); + } - $rendered = str_replace( - 'DummyModel', $model, $rendered - ); + protected function replaceModel($stub) + { + return str_replace(['{{ model }}', '{{model}}'], class_basename($this->guessQualifiedModel()), $stub); + } + + protected function replaceQualifiedModel($stub) + { + return str_replace('{{ modelQualified }}', $this->guessQualifiedModel(), $stub); + } + + protected function guessQualifiedModel(): string + { + $model = Str::singular(class_basename(Str::beforeLast($this->getNameInput(), 'Policy'))); - return $rendered; + return str_replace('/', '\\', $this->rootNamespace().'Models/'.$model); } - public function nameWithEnd() + protected function guessPolicyName() { - $model = $this->option('model'); + $name = $this->getNameInput(); - if (is_null($model)) { - $model = $this->argument('name'); + if (false === Str::endsWith($name, 'Policy')) { + $name .= 'Policy'; } - return $model.'Policy'; + return $name; } protected function getPath($name) { - return $this->laravel['path'].'/Policies/'.$this->nameWithEnd().'.php'; + return $this->laravel['path'].'/Policies/'.$this->guessPolicyName().'.php'; } /** @@ -105,12 +81,12 @@ protected function getStub() /** * Get the default namespace for the class. * - * @param string $rootNamespace + * @param string $rootNamespace * @return string */ protected function getDefaultNamespace($rootNamespace) { - return $rootNamespace.'\Restify'; + return $rootNamespace.'\Policies'; } /** @@ -122,6 +98,7 @@ protected function getOptions() { return [ ['model', 'm', InputOption::VALUE_REQUIRED, 'The model class being protected.'], + ['force', null, InputOption::VALUE_NONE, 'Create the class even if the model already exists.'], ]; } } diff --git a/src/Commands/RepositoryCommand.php b/src/Commands/RepositoryCommand.php index cc9229868..2612904e9 100644 --- a/src/Commands/RepositoryCommand.php +++ b/src/Commands/RepositoryCommand.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Commands; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; @@ -11,110 +12,161 @@ */ class RepositoryCommand extends GeneratorCommand { - /** - * The console command name. - * - * @var string - */ + use ConfirmableTrait; + protected $name = 'restify:repository'; - /** - * The console command description. - * - * @var string - */ protected $description = 'Create a new repository class'; - /** - * The type of class being generated. - * - * @var string - */ protected $type = 'Repository'; - /** - * Execute the console command. - * - * @return bool|null - * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException - */ public function handle() { - parent::handle(); + if (parent::handle() === false && ! $this->option('force')) { + return false; + } $this->callSilent('restify:base-repository', [ 'name' => 'Repository', ]); + + if ($this->option('all')) { + $this->input->setOption('factory', true); + $this->input->setOption('model', true); + $this->input->setOption('policy', true); + $this->input->setOption('table', true); + } + + if ($this->option('policy')) { + $this->buildPolicy(); + } + + if ($this->option('model')) { + $this->buildModel(); + } + + if ($this->option('table')) { + $this->buildMigration(); + } + + if ($this->option('factory')) { + $this->buildFactory(); + } } /** * Build the class with the given name. + * This method should return the file class content. * - * @param string $name + * @param string $name * @return string * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException */ protected function buildClass($name) { - $model = $this->option('model'); - - if (is_null($model)) { - $model = $this->laravel->getNamespace().$this->argument('name'); - } elseif (! Str::startsWith($model, [ - $this->laravel->getNamespace(), '\\', - ])) { - $model = $this->laravel->getNamespace().$model; + if (false === Str::endsWith($name, 'Repository')) { + $name .= 'Repository'; } - if ($this->option('all')) { - $this->call('make:model', [ - 'name' => $this->argument('name'), - '--factory' => true, - '--migration' => true, - '--controller' => true, - ]); - - $this->call('restify:policy', [ - 'name' => $this->argument('name'), - ]); + return $this->replaceModel(parent::buildClass($name), $this->guessQualifiedModelName()); + } + + protected function replaceModel($stub, $class) + { + return str_replace(['DummyClass', '{{ model }}', '{{model}}'], $class, $stub); + } + + protected function guessBaseModelClass() + { + return class_basename($this->guessQualifiedModelName()); + } + + protected function guessQualifiedModelName() + { + $model = Str::singular(class_basename(Str::before($this->getNameInput(), 'Repository'))); + + return str_replace('/', '\\', $this->rootNamespace().'/Models//'.$model); + } + + protected function buildMigration() + { + $table = Str::snake(Str::pluralStudly(class_basename($this->guessQualifiedModelName()))); + + $guessMigration = 'Create'.Str::studly($table).'Table'; + + if (false === class_exists($guessMigration)) { + $migration = Str::snake($guessMigration); + $yes = $this->confirm("Do you want to generate the migration [{$migration}]?"); + + if ($yes) { + $this->call('make:migration', [ + 'name' => $migration, + '--create' => $table, + ]); + } } + } + + protected function buildPolicy() + { + $this->call('restify:policy', [ + 'name' => $this->guessBaseModelClass(), + ]); - return str_replace( - 'DummyFullModel', $model, parent::buildClass($name) - ); + return $this; + } + + protected function buildModel() + { + $model = $this->guessQualifiedModelName(); + + if (false === class_exists($model)) { + $yes = $this->confirm("Do you want to generate the model [{$model}]?"); + + if ($yes) { + $this->call('make:model', ['name' => str_replace('\\\\', '\\', $model)]); + } + } + } + + protected function buildFactory() + { + $factory = Str::studly(class_basename($this->guessQualifiedModelName())); + + $this->call('make:factory', [ + 'name' => "{$factory}Factory", + '--model' => str_replace('\\\\', '\\', $this->guessQualifiedModelName()), + ]); } - /** - * Get the stub file for the generator. - * - * @return string - */ protected function getStub() { return __DIR__.'/stubs/repository.stub'; } - /** - * Get the default namespace for the class. - * - * @param string $rootNamespace - * @return string - */ + protected function getPath($name) + { + if (false === Str::endsWith($name, 'Repository')) { + $name .= 'Repository'; + } + + return parent::getPath($name); + } + protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Restify'; } - /** - * Get the console command options. - * - * @return array - */ protected function getOptions() { return [ ['all', 'a', InputOption::VALUE_NONE, 'Generate a migration, factory, and controller for the repository'], - ['model', 'm', InputOption::VALUE_REQUIRED, 'The model class being represented.'], + ['model', 'm', InputOption::VALUE_NONE, 'The model class being represented.'], + ['factory', 'f', InputOption::VALUE_NONE, 'Create a new factory for the repository model.'], + ['policy', 'p', InputOption::VALUE_NONE, 'Create a new policy for the repository model.'], + ['table', 't', InputOption::VALUE_NONE, 'Create a new migration table file for the repository model.'], + ['force', null, InputOption::VALUE_NONE, 'Create the class even if the model already exists.'], ]; } } diff --git a/src/Commands/SetupCommand.php b/src/Commands/SetupCommand.php index b32b9bf4e..7546a3292 100644 --- a/src/Commands/SetupCommand.php +++ b/src/Commands/SetupCommand.php @@ -7,25 +7,10 @@ class SetupCommand extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'restify:setup'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Prepare Restify dependencies and resources'; + protected $description = 'Should be run when you firstly instal the package. It will setup everything for you.'; - /** - * Execute the console command. - * - * @return void - */ public function handle() { $this->comment('Publishing Restify Service Provider...'); @@ -40,7 +25,7 @@ public function handle() $this->comment('Generating User Repository...'); $this->callSilent('restify:repository', ['name' => 'User']); - copy(__DIR__.'/stubs/user-repository.stub', app_path('Restify/User.php')); + copy(__DIR__.'/stubs/user-repository.stub', app_path('Restify/UserRepository.php')); $this->setAppNamespace(); diff --git a/src/Commands/StubCommand.php b/src/Commands/StubCommand.php new file mode 100644 index 000000000..3d886b144 --- /dev/null +++ b/src/Commands/StubCommand.php @@ -0,0 +1,102 @@ +resolver = $resolver; + $this->faker = $faker; + } + + public function handle() + { + if (! $this->confirmToProceed()) { + return true; + } + + if (! $this->resolver->connection()->getSchemaBuilder()->hasTable($table = $this->argument('table'))) { + return false; + } + + DB::connection()->getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string'); + + $start = microtime(true); + Collection::times($count = $this->option('count') ?? 1)->each(fn () => $this->make($table)); + + $time = round(microtime(true) - $start, 2); + + $this->info("Seeded {$count} {$table} in {$time} seconds"); + } + + protected function make($table) + { + $data = []; + + collect(Schema::getColumnListing($table))->each(function ($column) use (&$data, $table) { + $type = Schema::getColumnType($table, $column); + + switch ($type) { + case 'string': + $data[$column] = $this->faker->text(50); + + if (Str::contains($column, 'email')) { + $data[$column] = $this->faker->email; + } + + if (Str::contains($column, 'password')) { + $data[$column] = Hash::make('secret'); + } + + if (Str::contains($column, 'uuid')) { + $data[$column] = Str::orderedUuid(); + } + + if (Str::contains($column, 'image') || Str::contains($column, 'picture')) { + $data[$column] = $this->faker->imageUrl(); + } + break; + case 'datetime': + $data[$column] = Carbon::now(); + break; + case 'boolean': + $data[$column] = $this->faker->boolean; + break; + case 'biging': + case 'int': + if (false === Str::endsWith($column, 'id')) { + $data[$column] = $this->faker->id; + } + break; + } + }); + + $id = DB::table($table)->insertGetId($data); + + $this->info('Created '.Str::singular(Str::studly($table)).' with id:'.$id); + } +} diff --git a/src/Commands/stubs/base-repository.stub b/src/Commands/stubs/base-repository.stub index ddd0cfc6c..34578e01b 100644 --- a/src/Commands/stubs/base-repository.stub +++ b/src/Commands/stubs/base-repository.stub @@ -8,30 +8,6 @@ use Illuminate\Contracts\Pagination\Paginator; abstract class Repository extends RestifyRepository { - /** - * Format the response for the details request (single item) - * - * @param $request - * @param $serialized - * @return array - */ - public function serializeDetails($request, $serialized) - { - return $serialized; - } - - /** - * Format the response for the index request - * - * @param $request - * @param $serialized - * @return array - */ - public function serializeIndex($request, $serialized) - { - return $serialized; - } - /** * Build an "index" query for the given repository. * diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 820efadda..43746bc56 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -4,30 +4,19 @@ namespace DummyNamespace; use App\User; use Illuminate\Auth\Access\HandlesAuthorization; -use UseDummyModel; +use {{ modelQualified }}; -class DummyClass +class {{ class }} { use HandlesAuthorization; /** - * Determine whether the user can view any models. - * - * @param \App\User $user - * @return mixed - */ - public function showEvery(User $user = null) - { - // - } - - /** - * Determine whether the user is authorized to access the repository uriKey - * + * Determine whether the user can use restify feature for each CRUD operation. + * So if this is not allowed, all operations will be disabled * @param \App\User $user * @return mixed */ - public function showAny(User $user = null) + public function allowRestify(User $user = null) { // } @@ -36,10 +25,10 @@ class DummyClass * Determine whether the user can get the model. * * @param \App\User $user - * @param DummyModel $model + * @param {{ model }} $model * @return mixed */ - public function show(User $user, DummyModel $model) + public function show(User $user, {{ model }} $model) { // } @@ -59,10 +48,10 @@ class DummyClass * Determine whether the user can update the model. * * @param \App\User $user - * @param DummyModel $model + * @param {{ model }} $model * @return mixed */ - public function update(User $user, DummyModel $model) + public function update(User $user, {{ model }} $model) { // } @@ -71,10 +60,10 @@ class DummyClass * Determine whether the user can delete the model. * * @param \App\User $user - * @param DummyModel $model + * @param {{ model }} $model * @return mixed */ - public function delete(User $user, DummyModel $model) + public function delete(User $user, {{ model }} $model) { // } @@ -83,10 +72,10 @@ class DummyClass * Determine whether the user can restore the model. * * @param \App\User $user - * @param DummyModel $model + * @param {{ model }} $model * @return mixed */ - public function restore(User $user, DummyModel $model) + public function restore(User $user, {{ model }} $model) { // } @@ -95,10 +84,10 @@ class DummyClass * Determine whether the user can permanently delete the model. * * @param \App\User $user - * @param DummyModel $model + * @param {{ model }} $model * @return mixed */ - public function forceDelete(User $user, DummyModel $model) + public function forceDelete(User $user, {{ model }} $model) { // } diff --git a/src/Commands/stubs/repository.stub b/src/Commands/stubs/repository.stub index bd4652929..15ece54e1 100644 --- a/src/Commands/stubs/repository.stub +++ b/src/Commands/stubs/repository.stub @@ -12,7 +12,7 @@ class DummyClass extends Repository * * @var string */ - public static $model = 'DummyFullModel'; + public static $model = '{{ model }}'; /** * @param RestifyRequest $request diff --git a/src/Commands/stubs/user-repository.stub b/src/Commands/stubs/user-repository.stub index 77f410e5e..04856d28f 100644 --- a/src/Commands/stubs/user-repository.stub +++ b/src/Commands/stubs/user-repository.stub @@ -6,7 +6,7 @@ use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Illuminate\Support\Facades\Hash; -class User extends Repository +class UserRepository extends Repository { /** * The model the repository corresponds to. diff --git a/src/Controllers/RestResponse.php b/src/Controllers/RestResponse.php index 9f4905998..579039dd5 100644 --- a/src/Controllers/RestResponse.php +++ b/src/Controllers/RestResponse.php @@ -65,6 +65,24 @@ class RestResponse extends JsonResponse implements Responsable const REST_RESPONSE_SUCCESS_CODE = 200; const REST_RESPONSE_UNAVAILABLE_CODE = 503; + const CODES = [ + self::REST_RESPONSE_AUTH_CODE, + self::REST_RESPONSE_REFRESH_CODE, + self::REST_RESPONSE_CREATED_CODE, + self::REST_RESPONSE_UPDATED_CODE, + self::REST_RESPONSE_DELETED_CODE, + self::REST_RESPONSE_BLANK_CODE, + self::REST_RESPONSE_ERROR_CODE, + self::REST_RESPONSE_INVALID_CODE, + self::REST_RESPONSE_UNAUTHORIZED_CODE, + self::REST_RESPONSE_FORBIDDEN_CODE, + self::REST_RESPONSE_MISSING_CODE, + self::REST_RESPONSE_NOTFOUND_CODE, + self::REST_RESPONSE_THROTTLE_CODE, + self::REST_RESPONSE_SUCCESS_CODE, + self::REST_RESPONSE_UNAVAILABLE_CODE, + ]; + /** * @var ResponseFactory */ @@ -529,7 +547,9 @@ public function toResponse($request = null) } if ($this->code) { - $this->setStatusCode(is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE); + if (in_array($this->code(), static::CODES)) { + $this->setStatusCode(is_int($this->code()) ? $this->code() : self::REST_RESPONSE_SUCCESS_CODE); + } } if ($this->debug) { diff --git a/src/Events/RestifyRepositoryStored.php b/src/Events/RestifyRepositoryStored.php new file mode 100644 index 000000000..ced0c8fc6 --- /dev/null +++ b/src/Events/RestifyRepositoryStored.php @@ -0,0 +1,23 @@ +model = $model; + } +} diff --git a/src/Exceptions/InstanceOfException.php b/src/Exceptions/InstanceOfException.php index a26d0f1c0..42ec0cba6 100644 --- a/src/Exceptions/InstanceOfException.php +++ b/src/Exceptions/InstanceOfException.php @@ -9,4 +9,8 @@ */ class InstanceOfException extends Exception { + public static function because($message = '') + { + return new static($message); + } } diff --git a/src/Exceptions/RestifyHandler.php b/src/Exceptions/RestifyHandler.php index c8b1ec8b0..9b28c56d2 100644 --- a/src/Exceptions/RestifyHandler.php +++ b/src/Exceptions/RestifyHandler.php @@ -21,6 +21,7 @@ use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; @@ -81,7 +82,10 @@ public function render($request, $exception) break; case $exception instanceof ValidationException: - $response->errors($exception->errors())->invalid(); + $response->addError($exception->errors())->invalid(); + break; + case $exception instanceof InstanceOfException: + $response->errors($exception->getMessage())->invalid(); break; case $exception instanceof MethodNotAllowedHttpException: @@ -104,6 +108,9 @@ public function render($request, $exception) case $exception instanceof InvalidSignatureException: $response->addError($exception->getMessage())->forbidden(); break; + case $exception instanceof HttpException: + $response->addError($exception->getMessage())->forbidden(); + break; default: if (App::environment('production') === true) { diff --git a/src/Fields/BaseField.php b/src/Fields/BaseField.php index 73b0e6f18..366ce3db3 100644 --- a/src/Fields/BaseField.php +++ b/src/Fields/BaseField.php @@ -2,42 +2,6 @@ namespace Binaryk\LaravelRestify\Fields; -use Illuminate\Http\Request; - -/** - * @author Eduard Lupacescu - */ abstract class BaseField { - /** - * Conditionally load the field. - * - * @var bool|callable - */ - public $when = true; - - /** - * Conditionally load the field. - * - * @param callable|bool $condition - * @param bool $default - * @return $this - */ - public function when($condition, $default = false) - { - $this->when = $condition ?? $default; - - return $this; - } - - /** - * Conditionally load the field. - * - * @param Request $request - * @return bool|callable|mixed - */ - public function filter(Request $request) - { - return is_callable($this->when) ? call_user_func($this->when, $request) : $this->when; - } } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 29173609b..6564b619b 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -3,8 +3,11 @@ namespace Binaryk\LaravelRestify\Fields; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Traits\Make; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Support\Str; use JsonSerializable; /** @@ -12,12 +15,46 @@ */ class Field extends OrganicField implements JsonSerializable { + use Make; + + /** + * The resource associated with the field. + * + * @var Repository + */ + public $repository; + /** * Column name of the field. * @var string|callable|null */ public $attribute; + /** + * Field value. + * + * @var string|callable|null + */ + public $value; + + /** + * In case of the update, this will keep the previous value. + * @var + */ + public $valueBeforeUpdate; + + /** + * Closure to resolve the index method. + * + * @var + */ + private $indexCallback; + + /** + * @var Closure + */ + public $showCallback; + /** * Callback called when the value is filled, this callback will do not override the fill action. * @var Closure @@ -25,59 +62,76 @@ class Field extends OrganicField implements JsonSerializable public $storeCallback; /** + * Callback called when update. * @var Closure */ - public $showCallback; + public $updateCallback; /** - * Callback called when trying to fill this attribute, this callback will override the fill action, so make - * sure you assign the attribute to the model over this callback. + * Closure be used to resolve the field's value. + * + * @var \Closure + */ + public $resolveCallback; + + /** + * Callback called when trying to fill this attribute, this callback will override the storeCallback or updateCallback. + * + * Make sure you assign the attribute to the model over this callback. * * @var Closure */ public $fillCallback; /** - * Create a new field. + * Closure be used for computed field. * - * @param string|callable|null $attribute + * @var callable */ - public function __construct($attribute) - { - $this->attribute = $attribute; - } + protected $computedCallback; /** - * Create a new element. + * Closure be used for the field's default value. * - * @param array $arguments - * @return static + * @var callable */ - public static function make(...$arguments) - { - return new static(...$arguments); - } + protected $defaultCallback; /** - * {@inheritdoc} + * Closure be used to be called after the field value stored. */ - public function jsonSerialize() - { - return [ - 'value' => $this->value, - ]; - } + public $afterStoreCallback; + + /** + * Closure be used to be called after the field value changed. + */ + public $afterUpdateCallback; /** - * Callback called when the value is filled, this callback will do not override the fill action. If fillCallback is defined - * this will do not be called. + * Create a new field. * - * @param Closure $callback - * @return Field + * @param string|callable|null $attribute + * @param callable|null $resolveCallback */ - public function storeCallback(Closure $callback) + public function __construct($attribute, callable $resolveCallback = null) { - $this->storeCallback = $callback; + $this->attribute = $attribute; + + $this->resolveCallback = $resolveCallback; + + $this->default(null); + + if ($attribute instanceof Closure || (is_callable($attribute) && is_object($attribute))) { + $this->computedCallback = $attribute; + $this->attribute = 'Computed'; + } else { + $this->attribute = $attribute ?? str_replace(' ', '_', Str::lower($attribute)); + } + } + + public function indexCallback(Closure $callback) + { + $this->indexCallback = $callback; return $this; } @@ -93,6 +147,20 @@ public function showCallback(Closure $callback) return $this; } + public function storeCallback(Closure $callback) + { + $this->storeCallback = $callback; + + return $this; + } + + public function updateCallback(Closure $callback) + { + $this->updateCallback = $callback; + + return $this; + } + /** * Callback called when trying to fill this attribute, this callback will override the fill action, so make * sure you assign the attribute to the model over this callback. @@ -116,13 +184,27 @@ public function fillCallback(Closure $callback) */ public function fillAttribute(RestifyRequest $request, $model) { + $this->resolveValueBeforeUpdate($request, $model); + if (isset($this->fillCallback)) { return call_user_func( $this->fillCallback, $request, $model, $this->attribute ); } - return $this->fillAttributeFromRequest( + if ($request->isStoreRequest() && is_callable($this->storeCallback)) { + return call_user_func( + $this->storeCallback, $request, $model, $this->attribute + ); + } + + if ($request->isUpdateRequest() && is_callable($this->updateCallback)) { + return call_user_func( + $this->updateCallback, $request, $model, $this->attribute + ); + } + + $this->fillAttributeFromRequest( $request, $model, $this->attribute ); } @@ -137,9 +219,7 @@ public function fillAttribute(RestifyRequest $request, $model) protected function fillAttributeFromRequest(RestifyRequest $request, $model, $attribute) { if ($request->exists($attribute) || $request->get($attribute)) { - $value = $request[$attribute] ?? $request->get($attribute); - - $model->{$attribute} = is_callable($this->storeCallback) ? call_user_func($this->storeCallback, $value, $request, $model) : $value; + $model->{$attribute} = $request[$attribute] ?? $request->get($attribute); } } @@ -163,6 +243,17 @@ public function storingRules($rules) return $this; } + /** + * Alias for storingRules - to maintain it consistent. + * + * @param $rules + * @return $this + */ + public function storeRules($rules) + { + return $this->storingRules($rules); + } + /** * Validation rules for update. * @@ -188,12 +279,6 @@ public function rules($rules) return $this; } - /** - * Validation messages. - * - * @param array $messages - * @return Field - */ public function messages(array $messages) { $this->messages = $messages; @@ -201,20 +286,12 @@ public function messages(array $messages) return $this; } - /** - * Validation rules for storing. - * - * @return array - */ - public function getStoringRules() + public function getStoringRules(): array { return array_merge($this->rules, $this->storingRules); } - /** - * @return array - */ - public function getUpdatingRules() + public function getUpdatingRules(): array { return array_merge($this->rules, $this->updatingRules); } @@ -224,22 +301,72 @@ public function getUpdatingRules() * * @param mixed $repository * @param string|null $attribute - * @return callable|string + * @return Field|void */ public function resolveForShow($repository, $attribute = null) { $attribute = $attribute ?? $this->attribute; - if (is_callable($this->showCallback)) { - $value = $this->resolveAttribute($repository, $attribute); - $attribute = call_user_func($this->showCallback, $value, $repository, $attribute); + if ($attribute === 'Computed') { + $this->value = call_user_func($this->computedCallback, $repository); + + return; + } + + if (! $this->showCallback) { + $this->resolve($repository, $attribute); + } elseif (is_callable($this->showCallback)) { + tap($this->value ?? $this->resolveAttribute($repository, $attribute), function ($value) use ($repository, $attribute) { + $this->value = call_user_func($this->showCallback, $value, $repository, $attribute); + }); + } + + return $this; + } + + public function resolveForIndex($repository, $attribute = null) + { + $this->repository = $repository; + + $attribute = $attribute ?? $this->attribute; + + if ($attribute === 'Computed') { + $this->value = call_user_func($this->computedCallback, $repository); + + return; } - return $attribute; + if (! $this->indexCallback) { + $this->resolve($repository, $attribute); + } elseif (is_callable($this->indexCallback)) { + tap($this->value ?? $this->resolveAttribute($repository, $attribute), function ($value) use ($repository, $attribute) { + $this->value = call_user_func($this->indexCallback, $value, $repository, $attribute); + }); + } + + return $this; + } + + public function resolve($repository, $attribute = null) + { + $this->repository = $repository; + if ($attribute === 'Computed') { + $this->value = call_user_func($this->computedCallback, $repository); + + return; + } + + if (! $this->resolveCallback) { + $this->value = $this->resolveAttribute($repository, $attribute); + } elseif (is_callable($this->resolveCallback)) { + tap($this->resolveAttribute($repository, $attribute), function ($value) use ($repository, $attribute) { + $this->value = call_user_func($this->resolveCallback, $value, $repository, $attribute); + }); + } } /** - * Resolve the given attribute from the given resource. + * Resolve the given attribute from the given repository. * * @param mixed $repository * @param string $attribute @@ -249,4 +376,94 @@ protected function resolveAttribute($repository, $attribute) { return data_get($repository, str_replace('->', '.', $attribute)); } + + protected function resolveValueBeforeUpdate(RestifyRequest $request, $repository) + { + if ($request->isUpdateRequest()) { + $this->valueBeforeUpdate = $this->resolveAttribute($repository, $this->attribute); + } + } + + public function jsonSerialize() + { + return with(app(RestifyRequest::class), function ($request) { + return [ + 'attribute' => $this->attribute, + 'value' => $this->resolveDefaultValue($request) ?? $this->value, + ]; + }); + } + + public function serializeToValue($request) + { + return [ + $this->attribute => $this->resolveDefaultValue($request) ?? $this->value, + ]; + } + + /** + * Set the callback to be used for determining the field's default value. + * + * @param $callback + * @return $this + */ + public function default($callback) + { + $this->defaultCallback = $callback; + + return $this; + } + + /** + * Resolve the default value for the field. + * + * @param RestifyRequest $request + * @return callable|mixed + */ + protected function resolveDefaultValue(RestifyRequest $request) + { + if (is_null($this->value) && is_callable($this->defaultCallback)) { + return call_user_func($this->defaultCallback, $request); + } + + return $this->defaultCallback; + } + + /** + * Define the callback that should be used to resolve the field's value. + * + * @param callable $resolveCallback + * @return $this + */ + public function resolveUsing(callable $resolveCallback) + { + $this->resolveCallback = $resolveCallback; + + return $this; + } + + public function afterUpdate(Closure $callback) + { + $this->afterUpdateCallback = $callback; + + return $this; + } + + public function afterStore(Closure $callback) + { + $this->afterStoreCallback = $callback; + + return $this; + } + + public function invokeAfter(RestifyRequest $request, $repository) + { + if ($request->isStoreRequest() && is_callable($this->afterStoreCallback)) { + call_user_func($this->afterStoreCallback, data_get($repository, $this->attribute), $repository, $request); + } + + if ($request->isUpdateRequest() && is_callable($this->afterUpdateCallback)) { + call_user_func($this->afterUpdateCallback, $this->resolveAttribute($repository, $this->attribute), $this->valueBeforeUpdate, $repository, $request); + } + } } diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php new file mode 100644 index 000000000..152afe843 --- /dev/null +++ b/src/Fields/FieldCollection.php @@ -0,0 +1,66 @@ +filter(function (OrganicField $field) use ($request) { + return $field->authorize($request); + })->values(); + } + + public function authorizedUpdate(Request $request): self + { + return $this->filter(function (OrganicField $field) use ($request) { + return $field->authorizedToUpdate($request); + })->values(); + } + + public function authorizedStore(Request $request): self + { + return $this->filter(function (OrganicField $field) use ($request) { + return $field->authorizedToStore($request); + })->values(); + } + + public function resolve($repository): self + { + return $this->each(function ($field) use ($repository) { + $field->resolve($repository); + }); + } + + public function forIndex(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnIndex($request, $repository); + })->values(); + } + + public function forShow(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnShow($request, $repository); + })->values(); + } + + public function forStore(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnStore($request, $repository); + })->values(); + } + + public function forUpdate(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnUpdate($request, $repository); + })->values(); + } +} diff --git a/src/Fields/OrganicField.php b/src/Fields/OrganicField.php index 385f2b28e..a6c03b3f9 100644 --- a/src/Fields/OrganicField.php +++ b/src/Fields/OrganicField.php @@ -3,70 +3,38 @@ namespace Binaryk\LaravelRestify\Fields; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Closure; +use Illuminate\Http\Request; -/** - * @author Eduard Lupacescu - */ abstract class OrganicField extends BaseField { - /** - * Rules for applied when store. - * - * @var array - */ - public $storingRules = []; - - /** - * Rules for applied when update model. - * @var array - */ - public $updatingRules = []; - - /** - * Rules for applied when store and update. - * - * @var array - */ - public $rules = []; - - /** - * @var array - */ - public $messages = []; - - /** - * Indicates if the element should be shown on the index view. - * - * @var \Closure|bool - */ + public $canSeeCallback; + + public $canUpdateCallback; + + public $canStoreCallback; + + public $readonlyCallback; + + public array $rules = []; + + public array $storingRules = []; + + public array $updatingRules = []; + + public array $messages = []; + public $showOnIndex = true; - /** - * Indicates if the element should be shown on the detail view. - * - * @var \Closure|bool - */ - public $showOnDetail = true; + public $showOnShow = true; - /** - * Specify that the element should be hidden from the detail view. - * - * @param \Closure|bool $callback - * @return $this - */ - public function showOnDetail($callback = true) + public function showOnShow($callback = true) { - $this->showOnDetail = $callback; + $this->showOnShow = $callback; return $this; } - /** - * Specify that the element should be hidden from the detail view. - * - * @param \Closure|bool $callback - * @return $this - */ public function showOnIndex($callback = true) { $this->showOnIndex = $callback; @@ -74,15 +42,9 @@ public function showOnIndex($callback = true) return $this; } - /** - * Specify that the element should be hidden from the detail view. - * - * @param \Closure|bool $callback - * @return $this - */ - public function hideFromDetail($callback = true) + public function hideFromShow($callback = true) { - $this->showOnDetail = is_callable($callback) ? function () use ($callback) { + $this->showOnShow = is_callable($callback) ? function () use ($callback) { return ! call_user_func_array($callback, func_get_args()); } : ! $callback; @@ -90,12 +52,6 @@ public function hideFromDetail($callback = true) return $this; } - /** - * Specify that the element should be hidden from the index view. - * - * @param \Closure|bool $callback - * @return $this - */ public function hideFromIndex($callback = true) { $this->showOnIndex = is_callable($callback) ? function () use ($callback) { @@ -106,45 +62,25 @@ public function hideFromIndex($callback = true) return $this; } - /** - * Check showing on detail. - * - * @param RestifyRequest $request - * @param mixed $repository - * @return bool - */ - public function isShownOnDetail(RestifyRequest $request, $repository): bool + public function isShownOnShow(RestifyRequest $request, $repository): bool { - if (is_callable($this->showOnDetail)) { - $this->showOnDetail = call_user_func($this->showOnDetail, $request, $repository); + if (is_callable($this->showOnShow)) { + $this->showOnShow = call_user_func($this->showOnShow, $request, $repository); } - return $this->showOnDetail; + return $this->showOnShow; } - /** - * Check hidden on detail. - * - * @param RestifyRequest $request - * @param mixed $repository - * @return bool - */ - public function isHiddenOnDetail(RestifyRequest $request, $repository): bool + public function isHiddenOnShow(RestifyRequest $request, $repository): bool { - if (is_callable($this->showOnDetail)) { - $this->showOnDetail = call_user_func($this->showOnDetail, $request, $repository); - } + return false === $this->isShownOnShow($request, $repository); + } - return ! $this->showOnDetail; + public function isShownOnIndex(RestifyRequest $request, $repository): bool + { + return false === $this->isHiddenOnIndex($request, $repository); } - /** - * Check hidden on index. - * - * @param RestifyRequest $request - * @param mixed $repository - * @return bool - */ public function isHiddenOnIndex(RestifyRequest $request, $repository): bool { if (is_callable($this->showOnIndex)) { @@ -153,4 +89,73 @@ public function isHiddenOnIndex(RestifyRequest $request, $repository): bool return ! $this->showOnIndex; } + + public function authorize(Request $request) + { + return $this->authorizedToSee($request); + } + + public function authorizedToSee(Request $request) + { + return $this->canSeeCallback ? call_user_func($this->canSeeCallback, $request) : true; + } + + public function authorizedToUpdate(Request $request) + { + return $this->canUpdateCallback ? call_user_func($this->canUpdateCallback, $request) : true; + } + + public function authorizedToStore(Request $request) + { + return $this->canStoreCallback ? call_user_func($this->canStoreCallback, $request) : true; + } + + public function canSee(Closure $callback) + { + $this->canSeeCallback = $callback; + + return $this; + } + + public function canUpdate(Closure $callback) + { + $this->canUpdateCallback = $callback; + + return $this; + } + + public function canStore(Closure $callback) + { + $this->canStoreCallback = $callback; + + return $this; + } + + public function readonly($callback = true) + { + $this->readonlyCallback = $callback; + + return $this; + } + + public function isReadonly(RestifyRequest $request) + { + return with($this->readonlyCallback, function ($callback) use ($request) { + if ($callback === true || (is_callable($callback) && call_user_func($callback, $request))) { + return true; + } + + return false; + }); + } + + public function isShownOnUpdate(RestifyRequest $request, $repository): bool + { + return ! $this->isReadonly($request); + } + + public function isShownOnStore(RestifyRequest $request, $repository): bool + { + return ! $this->isReadonly($request); + } } diff --git a/src/Http/Controllers/RepositoryDestroyController.php b/src/Http/Controllers/RepositoryDestroyController.php index 9d26663b9..252c76203 100644 --- a/src/Http/Controllers/RepositoryDestroyController.php +++ b/src/Http/Controllers/RepositoryDestroyController.php @@ -2,35 +2,13 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException; -use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; use Binaryk\LaravelRestify\Http\Requests\RepositoryDestroyRequest; -use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Http\JsonResponse; -use Throwable; -/** - * @author Eduard Lupacescu - */ class RepositoryDestroyController extends RepositoryController { - /** - * @param RepositoryDestroyRequest $request - * @return JsonResponse - * @throws AuthorizationException - * @throws BindingResolutionException - * @throws EntityNotFoundException - * @throws Throwable - * @throws UnauthorizedException - */ - public function handle(RepositoryDestroyRequest $request) + public function __invoke(RepositoryDestroyRequest $request) { - /** - * @var Repository - */ - $repository = $request->newRepositoryWith($request->findModelQuery()->firstOrFail()); + $repository = $request->newRepositoryWith($request->findModelQuery()->firstOrFail())->allowToDestroy($request); return $repository->destroy($request, request('repositoryId')); } diff --git a/src/Http/Controllers/RepositoryIndexController.php b/src/Http/Controllers/RepositoryIndexController.php index c186a3a98..f8127fe55 100644 --- a/src/Http/Controllers/RepositoryIndexController.php +++ b/src/Http/Controllers/RepositoryIndexController.php @@ -3,25 +3,12 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException; -use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryIndexRequest; -/** - * @author Eduard Lupacescu - */ class RepositoryIndexController extends RepositoryController { - /** - * @param RestifyRequest $request - * @return \Binaryk\LaravelRestify\Repositories\Repository - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - * @throws \Binaryk\LaravelRestify\Exceptions\InstanceOfException - * @throws \Binaryk\LaravelRestify\Exceptions\UnauthorizedException - * @throws \Illuminate\Contracts\Container\BindingResolutionException - * @throws \Throwable - */ - public function handle(RestifyRequest $request) + public function __invoke(RepositoryIndexRequest $request) { try { return $request->newRepository()->index($request); @@ -31,7 +18,7 @@ public function handle(RestifyRequest $request) ->dump($e, $request->isDev()); } catch (UnauthorizedException $e) { return $this->response()->forbidden()->addError($e->getMessage())->dump($e, $request->isDev()); - } catch (InstanceOfException | \Throwable $e) { + } catch (\Throwable $e) { return $this->response()->error()->dump($e, $request->isDev()); } } diff --git a/src/Http/Controllers/RepositoryShowController.php b/src/Http/Controllers/RepositoryShowController.php index f9b78be6e..70b8c380c 100644 --- a/src/Http/Controllers/RepositoryShowController.php +++ b/src/Http/Controllers/RepositoryShowController.php @@ -2,21 +2,14 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryShowRequest; -/** - * @author Eduard Lupacescu - */ class RepositoryShowController extends RepositoryController { - /** - * @param RestifyRequest $request - * @return \Binaryk\LaravelRestify\Controllers\RestResponse|mixed - * @throws \Illuminate\Auth\Access\AuthorizationException - * @throws \Throwable - */ - public function handle(RestifyRequest $request) + public function __invoke(RepositoryShowRequest $request) { - return $request->newRepositoryWith($request->findModelQuery())->show($request, request('repositoryId')); + return $request->newRepositoryWith($request->findModelQuery()->firstOrFail()) + ->allowToShow($request) + ->show($request, request('repositoryId')); } } diff --git a/src/Http/Controllers/RepositoryStoreController.php b/src/Http/Controllers/RepositoryStoreController.php index f14aa555f..caa7d4372 100644 --- a/src/Http/Controllers/RepositoryStoreController.php +++ b/src/Http/Controllers/RepositoryStoreController.php @@ -2,36 +2,14 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException; -use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; -use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Http\JsonResponse; -use Throwable; -/** - * @author Eduard Lupacescu - */ class RepositoryStoreController extends RepositoryController { - /** - * @param RepositoryStoreRequest $request - * @return JsonResponse - * @throws BindingResolutionException - * @throws EntityNotFoundException - * @throws UnauthorizedException - * @throws AuthorizationException - * @throws Throwable - */ - public function handle(RepositoryStoreRequest $request) + public function __invoke(RepositoryStoreRequest $request) { - /** - * @var Repository - */ - $repository = $request->repository(); - - return $request->newRepositoryWith($repository::newModel())->store($request); + return $request->repository() + ->allowToStore($request) + ->store($request); } } diff --git a/src/Http/Controllers/RepositoryUpdateController.php b/src/Http/Controllers/RepositoryUpdateController.php index 22394c3b3..0368e8949 100644 --- a/src/Http/Controllers/RepositoryUpdateController.php +++ b/src/Http/Controllers/RepositoryUpdateController.php @@ -2,39 +2,18 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException; -use Binaryk\LaravelRestify\Exceptions\UnauthorizedException; -use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateRequest; use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Http\JsonResponse; -use Throwable; -/** - * @author Eduard Lupacescu - */ class RepositoryUpdateController extends RepositoryController { - /** - * @param RepositoryStoreRequest $request - * @return JsonResponse - * @throws BindingResolutionException - * @throws EntityNotFoundException - * @throws UnauthorizedException - * @throws AuthorizationException - * @throws Throwable - */ - public function handle(RepositoryUpdateRequest $request) + public function __invoke(RepositoryUpdateRequest $request) { $model = $request->findModelQuery()->lockForUpdate()->firstOrFail(); - /** - * @var Repository - */ + /** * @var Repository $repository */ $repository = $request->newRepositoryWith($model); - return $repository->update($request, request('repositoryId')); + return $repository->allowToUpdate($request)->update($request, request('repositoryId')); } } diff --git a/src/Http/Requests/InteractWithRepositories.php b/src/Http/Requests/InteractWithRepositories.php index 10684ccb1..8513ae7b5 100644 --- a/src/Http/Requests/InteractWithRepositories.php +++ b/src/Http/Requests/InteractWithRepositories.php @@ -34,21 +34,24 @@ public function authorize() * @param null $key * @return Repository */ - public function repository($key = null) + public function repository($key = null): ?Repository { - return tap(Restify::repositoryForKey($key ?? $this->route('repository')), function ($repository) { + $repository = tap(Restify::repositoryForKey($key ?? $this->route('repository')), function ($repository) { + /** * @var Repository $repository */ if (is_null($repository)) { throw new EntityNotFoundException(__('Repository :name not found.', [ 'name' => $repository, ]), 404); } - if (! $repository::authorizedToShowAny($this)) { - throw new UnauthorizedException(__('Unauthorized to view repository :name. See "showAny" policy.', [ + if (! $repository::authorizedToUseRepository($this)) { + throw new UnauthorizedException(__('Unauthorized to view repository :name. See "allowRestify" policy.', [ 'name' => $repository, ]), 403); } }); + + return $repository::resolveWith($repository::newModel()); } /** @@ -66,8 +69,8 @@ public function rules() /** * Get the route handling the request. * - * @param string|null $param - * @param mixed $default + * @param string|null $param + * @param mixed $default * @return \Illuminate\Routing\Route|object|string */ abstract public function route($param = null, $default = null); diff --git a/src/Http/Requests/ResourceRequest.php b/src/Http/Requests/RepositoryShowRequest.php similarity index 71% rename from src/Http/Requests/ResourceRequest.php rename to src/Http/Requests/RepositoryShowRequest.php index a78231bd4..383777564 100644 --- a/src/Http/Requests/ResourceRequest.php +++ b/src/Http/Requests/RepositoryShowRequest.php @@ -5,6 +5,6 @@ /** * @author Eduard Lupacescu */ -class ResourceRequest extends RestifyRequest +class RepositoryShowRequest extends RestifyRequest { } diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index ce4a5cac8..5cc6cff1d 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -2,7 +2,6 @@ namespace Binaryk\LaravelRestify\Http\Requests; -use Binaryk\LaravelRestify\Restify; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\App; @@ -36,9 +35,7 @@ public function isDev() */ public function isIndexRequest() { - $path = trim(Restify::path($this->route('repository')), '/') ?: '/'; - - return $this->is($path); + return $this instanceof RepositoryIndexRequest; } /** @@ -46,8 +43,18 @@ public function isIndexRequest() * This will match any verbs (PATCH, DELETE or GET). * @return bool */ - public function isDetailRequest() + public function isShowRequest() + { + return $this instanceof RepositoryShowRequest; + } + + public function isUpdateRequest() + { + return $this instanceof RepositoryUpdateRequest; + } + + public function isStoreRequest() { - return ! $this->isIndexRequest(); + return $this instanceof RepositoryStoreRequest; } } diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 799b73214..8583871d6 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -8,6 +8,7 @@ use Binaryk\LaravelRestify\Commands\Refresh; use Binaryk\LaravelRestify\Commands\RepositoryCommand; use Binaryk\LaravelRestify\Commands\SetupCommand; +use Binaryk\LaravelRestify\Commands\StubCommand; use Binaryk\LaravelRestify\Http\Middleware\RestifyInjector; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Support\ServiceProvider; @@ -26,6 +27,7 @@ public function boot() PolicyCommand::class, BaseRepositoryCommand::class, Refresh::class, + StubCommand::class, ]); $this->registerPublishing(); diff --git a/src/Repositories/Crudable.php b/src/Repositories/Crudable.php deleted file mode 100644 index c45d79a64..000000000 --- a/src/Repositories/Crudable.php +++ /dev/null @@ -1,349 +0,0 @@ - - */ -trait Crudable -{ - /** - * @param RestifyRequest $request - * @return JsonResponse - * @throws \Binaryk\LaravelRestify\Exceptions\InstanceOfException - * @throws \Throwable - */ - public function index(RestifyRequest $request) - { - $results = SearchService::instance()->search($request, $this->model()); - - $results = $results->tap(function ($query) use ($request) { - static::indexQuery($request, $query); - }); - - /** - * @var AbstractPaginator - */ - $paginator = $results->paginate($request->get('perPage') ?? (static::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); - - $items = $paginator->getCollection()->map(function ($value) { - return static::resolveWith($value); - }); - - try { - $this->allowToShowEvery($request, $items); - } catch (UnauthorizedException | AuthorizationException $e) { - return $this->response()->forbidden()->addError($e->getMessage()); - } - - // Filter out items the request user don't have enough permissions for show - $items = $items->filter(function ($repository) use ($request) { - return $repository->authorizedToShow($request); - }); - - return $this->response([ - 'meta' => RepositoryCollection::meta($paginator->toArray()), - 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), - 'data' => $items, - ]); - } - - /** - * @param RestifyRequest $request - * @param $repositoryId - * @return JsonResponse - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public function show(RestifyRequest $request, $repositoryId) - { - $this->resource = static::showPlain($repositoryId); - - try { - $this->allowToShow($request); - } catch (AuthorizationException $e) { - return $this->response()->forbidden()->addError($e->getMessage()); - } - - return $this->response()->data($this->jsonSerialize()); - } - - /** - * @param RestifyRequest $request - * @return JsonResponse - * @throws AuthorizationException - * @throws ValidationException - */ - public function store(RestifyRequest $request) - { - try { - $this->allowToStore($request); - } catch (AuthorizationException | UnauthorizedException $e) { - return $this->response()->addError($e->getMessage())->code(RestResponse::REST_RESPONSE_FORBIDDEN_CODE); - } catch (ValidationException $e) { - return $this->response()->addError($e->errors()) - ->code(RestResponse::REST_RESPONSE_INVALID_CODE); - } - - $this->resource = static::storePlain($request->toArray()); - - static::stored($this->resource); - - return $this->response('', RestResponse::REST_RESPONSE_CREATED_CODE) - ->model($this->resource) - ->header('Location', Restify::path().'/'.static::uriKey().'/'.$this->resource->id); - } - - /** - * @param RestifyRequest $request - * @param $repositoryId - * @return JsonResponse - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws ValidationException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public function update(RestifyRequest $request, $repositoryId) - { - $this->allowToUpdate($request); - - $this->resource = static::updatePlain($request->all(), $repositoryId); - - static::updated($this->resource); - - return $this->response() - ->data($this->jsonSerialize()) - ->updated(); - } - - /** - * @param RestifyRequest $request - * @param $repositoryId - * @return JsonResponse - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public function destroy(RestifyRequest $request, $repositoryId) - { - $this->allowToDestroy($request); - - $status = static::destroyPlain($repositoryId); - - static::deleted($status); - - return $this->response()->deleted(); - } - - /** - * @param RestifyRequest $request - * @param array $payload - * @return mixed - */ - public function allowToUpdate(RestifyRequest $request, $payload = null) - { - $this->authorizeToUpdate($request); - - $validator = static::validatorForUpdate($request, $this, $payload); - - $validator->validate(); - } - - /** - * @param RestifyRequest $request - * @param array $payload - * @return mixed - */ - public function allowToStore(RestifyRequest $request, $payload = null) - { - static::authorizeToStore($request); - - $validator = static::validatorForStoring($request, $payload); - - $validator->validate(); - } - - /** - * @param RestifyRequest $request - * @throws \Illuminate\Auth\Access\AuthorizationException - */ - public function allowToDestroy(RestifyRequest $request) - { - $this->authorizeToDelete($request); - } - - /** - * @param $request - * @throws \Illuminate\Auth\Access\AuthorizationException - */ - public function allowToShow($request) - { - $this->authorizeToShow($request); - } - - /** - * @param $request - * @param Collection $items - * @throws \Illuminate\Auth\Access\AuthorizationException - */ - public function allowToShowEvery($request, Collection $items) - { - $this->authorizeToShowEvery($request); - } - - /** - * Validate input array and store a new entity. - * - * @param array $payload - * @return mixed - * @throws AuthorizationException - * @throws ValidationException - */ - public static function storePlain(array $payload) - { - /** * @var RepositoryStoreRequest $request */ - $request = resolve(RepositoryStoreRequest::class); - - $request->attributes->add($payload); - - $repository = resolve(static::class); - - $repository->allowToStore($request, $payload); - - return DB::transaction(function () use ($request) { - $model = static::fillWhenStore( - $request, static::newModel() - ); - - $model->save(); - - return $model; - }); - } - - /** - * Update an entity with an array of payload. - * - * @param array $payload - * @param $id - * @return mixed - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws ValidationException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public static function updatePlain(array $payload, $id) - { - /** * @var RepositoryUpdateRequest $request */ - $request = resolve(RepositoryUpdateRequest::class); - $request->attributes->add($payload); - - $model = $request->findModelQuery($id, static::uriKey())->lockForUpdate()->firstOrFail(); - - /** - * @var Repository - */ - $repository = $request->newRepositoryWith($model, static::uriKey()); - - $repository->allowToUpdate($request, $payload); - - return DB::transaction(function () use ($request, $repository) { - $model = static::fillWhenUpdate($request, $repository->resource); - - $model->save(); - - return $model; - }); - } - - /** - * Returns a plain model by key - * Used as: Book::showPlain(1). - * - * @param $key - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Model - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public static function showPlain($key) - { - /** * @var RestifyRequest $request */ - $request = resolve(RestifyRequest::class); - - /** - * Dive into the Search service to attach relations. - */ - $repository = $request->newRepositoryWith(tap($request->findModelQuery($key, static::uriKey())->firstOrFail(), function ($query) use ($request) { - static::detailQuery($request, $query); - })); - - $repository->allowToShow($request); - - return $repository->resource; - } - - /** - * Validate deletion and delete entity. - * - * @param $key - * @return mixed - * @throws AuthorizationException - * @throws UnauthorizedException - * @throws \Binaryk\LaravelRestify\Exceptions\Eloquent\EntityNotFoundException - */ - public static function destroyPlain($key) - { - /** * @var RepositoryDestroyRequest $request */ - $request = resolve(RepositoryDestroyRequest::class); - - $repository = $request->newRepositoryWith($request->findModelQuery($key, static::uriKey())->firstOrFail(), static::uriKey()); - - $repository->allowToDestroy($request); - - return DB::transaction(function () use ($repository) { - return $repository->resource->delete(); - }); - } - - /** - * @param $model - */ - public static function stored($model) - { - // - } - - /** - * @param $model - */ - public static function updated($model) - { - // - } - - /** - * @param int $status - */ - public static function deleted($status) - { - // - } -} diff --git a/src/Repositories/Mergeable.php b/src/Repositories/Mergeable.php new file mode 100644 index 000000000..dcdd60436 --- /dev/null +++ b/src/Repositories/Mergeable.php @@ -0,0 +1,11 @@ + users + * e.g. LaravelEntityRepository => laravel-entities. + */ + return Str::plural($kebabWithoutRepository); } /** @@ -74,7 +111,7 @@ public static function uriKey() * * @return mixed */ - public static function newModel() + public static function newModel(): Model { if (property_exists(static::class, 'model')) { $model = static::$model; @@ -85,33 +122,11 @@ public static function newModel() return new $model; } - /** - * @return Builder - */ - public static function query() + public static function query(): Builder { return static::newModel()->query(); } - /** - * @param $request - * @return array - */ - public function toArray(RestifyRequest $request) - { - $serialized = [ - 'id' => $this->when($this->resource instanceof Model, function () { - return $this->resource->getKey(); - }), - 'type' => $this->model()->getTable(), - 'attributes' => $this->resolveDetailsAttributes($request), - 'relationships' => $this->when(value($this->resolveDetailsRelationships($request)), $this->resolveDetailsRelationships($request)), - 'meta' => $this->when(value($this->resolveDetailsMeta($request)), $this->resolveDetailsMeta($request)), - ]; - - return $this->serializeDetails($request, $serialized); - } - /** * Resolvable attributes before storing/updating. * @@ -125,13 +140,67 @@ public function fields(RestifyRequest $request) /** * @param RestifyRequest $request - * @return Collection + * @return FieldCollection */ public function collectFields(RestifyRequest $request) { - return collect($this->fields($request))->filter(function (Field $field) use ($request) { - return $field->filter($request); - }); + $method = 'fields'; + + if ($request->isIndexRequest() && method_exists($this, 'fieldsForIndex')) { + $method = 'fieldsForIndex'; + } + + if ($request->isShowRequest() && method_exists($this, 'fieldsForShow')) { + $method = 'fieldsForShow'; + } + + if ($request->isUpdateRequest() && method_exists($this, 'fieldsForUpdate')) { + $method = 'fieldsForUpdate'; + } + + if ($request->isStoreRequest() && method_exists($this, 'fieldsForStore')) { + $method = 'fieldsForStore'; + } + + $fields = FieldCollection::make(array_values($this->filter($this->{$method}($request)))); + + if ($this instanceof Mergeable) { + $fillable = collect($this->resource->getFillable()) + ->filter(fn ($attribute) => $fields->contains('attribute', $attribute) === false) + ->map(fn ($attribute) => Field::new($attribute)); + + $fields = $fields->merge($fillable); + } + + return $fields; + } + + private function indexFields(RestifyRequest $request): Collection + { + return $this->collectFields($request) + ->filter(fn (Field $field) => ! $field->isHiddenOnIndex($request, $this)) + ->values(); + } + + private function showFields(RestifyRequest $request): Collection + { + return $this->collectFields($request) + ->filter(fn (Field $field) => ! $field->isHiddenOnShow($request, $this)) + ->values(); + } + + private function updateFields(RestifyRequest $request) + { + return $this->collectFields($request) + ->forUpdate($request, $this) + ->authorizedUpdate($request); + } + + private function storeFields(RestifyRequest $request) + { + return $this->collectFields($request) + ->forStore($request, $this) + ->authorizedStore($request); } /** @@ -147,14 +216,13 @@ public function withResource($resource) /** * Resolve repository with given model. + * * @param $model * @return Repository */ public static function resolveWith($model) { - /** - * @var Repository - */ + /** * @var Repository $self */ $self = resolve(static::class); return $self->withResource($model); @@ -206,42 +274,353 @@ public static function routes(Router $router, $attributes, $wrap = false) } /** - * Resolve the resource to an array. + * Return the attributes list. * - * @param \Illuminate\Http\Request|null $request + * Resolve all model fields through showCallback methods and exclude from the final response if + * that is required by method + * + * @param $request * @return array */ - public function resolve($request = null) + public function resolveShowAttributes(RestifyRequest $request) { - $data = $this->toArray( - $request = $request ?: Container::getInstance()->make(RestifyRequest::class) - ); + $fields = $this->showFields($request) + ->filter(fn (Field $field) => $field->authorize($request)) + ->each(fn (Field $field) => $field->resolveForShow($this)) + ->map(fn (Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn ($value) => $value) + ->all(); + + if ($this instanceof Mergeable) { + // Hiden and authorized index fields + $fields = $this->modelAttributes($request) + ->filter(function ($value, $attribute) use ($request) { + /** * @var Field $field */ + $field = $this->collectFields($request)->firstWhere('attribute', $attribute); + + if (is_null($field)) { + return true; + } + + if ($field->isHiddenOnShow($request, $this)) { + return false; + } + + if (! $field->authorize($request)) { + return false; + } - if ($data instanceof Arrayable) { - $data = $data->toArray(); - } elseif ($data instanceof JsonSerializable) { - $data = $data->jsonSerialize(); + return true; + })->all(); } - return $this->filter((array) $data); + return $fields; } /** - * @return array|mixed + * Return the attributes list. + * + * @param RestifyRequest $request + * @return array */ - public function jsonSerialize() + public function resolveIndexAttributes($request) + { + // Resolve the show method, and attach the value to the array + $fields = $this->indexFields($request) + ->filter(fn (Field $field) => $field->authorize($request)) + ->each(fn (Field $field) => $field->resolveForIndex($this)) + ->map(fn (Field $field) => $field->serializeToValue($request)) + ->mapWithKeys(fn ($value) => $value) + ->all(); + + if ($this instanceof Mergeable) { + // Hiden and authorized index fields + $fields = $this->modelAttributes($request) + ->filter(function ($value, $attribute) use ($request) { + /** * @var Field $field */ + $field = $this->collectFields($request)->firstWhere('attribute', $attribute); + + if (is_null($field)) { + return true; + } + + if ($field->isHiddenOnIndex($request, $this)) { + return false; + } + + if (! $field->authorize($request)) { + return false; + } + + return true; + })->all(); + } + + return $fields; + } + + /** + * @param $request + * @return array + */ + public function resolveDetailsMeta($request) + { + return [ + 'authorizedToShow' => $this->authorizedToShow($request), + 'authorizedToStore' => $this->authorizedToStore($request), + 'authorizedToUpdate' => $this->authorizedToUpdate($request), + 'authorizedToDelete' => $this->authorizedToDelete($request), + ]; + } + + /** + * Return a list with relationship for the current model. + * + * @param $request + * @return array + */ + public function resolveRelationships($request): array { - return $this->resolve(); + if (is_null($request->get('related'))) { + return []; + } + + $withs = []; + + with(explode(',', $request->get('related')), function ($relations) use ($request, &$withs) { + foreach ($relations as $relation) { + if (in_array($relation, static::getRelated())) { + // @todo check if the resource has the relation + /** * @var AbstractPaginator $paginator */ + $paginator = $this->resource->{$relation}()->paginate($request->get('relatablePerPage') ?? (static::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)); + + $withs[$relation] = $paginator->getCollection()->map(fn (Model $item) => [ + 'attributes' => $item->toArray(), + ]); + } + } + }); + + return $withs; } /** - * @param string $content - * @param int $status - * @param array $headers - * @return RestResponse + * @param $request + * @return array */ - public function response($content = '', $status = 200, array $headers = []) + public function resolveIndexMeta($request) + { + return $this->resolveDetailsMeta($request); + } + + /** + * Return a list with relationship for the current model. + * + * @param $request + * @return array + */ + public function resolveIndexRelationships($request) + { + return $this->resolveRelationships($request); + } + + public function index(RestifyRequest $request) + { + // Check if the user has the policy allowRestify + + // Check if the model was set under the repository + throw_if($this->model() instanceof NullModel, InstanceOfException::because(__('Model is not defined in the repository.'))); + + /** * + * Apply all of the query: search, match, sort, related. + * @var AbstractPaginator $paginator + */ + $paginator = RepositorySearchService::instance()->search($request, $this)->tap(function ($query) use ($request) { + // Call the local definition of the query + static::indexQuery($request, $query); + })->paginate($request->perPage ?? (static::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); + + $items = $paginator->getCollection()->map(function ($value) { + return static::resolveWith($value); + })->filter(function (self $repository) use ($request) { + return $repository->authorizedToShow($request); + })->values()->map(fn (self $repository) => $repository->serializeForIndex($request)); + + return $this->response([ + 'meta' => RepositoryCollection::meta($paginator->toArray()), + 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), + 'data' => $items, + ]); + } + + public function show(RestifyRequest $request, $repositoryId) + { + return $this->response()->data($this->serializeForShow($request)); + } + + public function store(RestifyRequest $request) + { + DB::transaction(function () use ($request) { + static::fillFields( + $request, $this->resource, $this->storeFields($request) + ); + + $this->resource->save(); + + $this->storeFields($request)->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); + }); + + static::stored($this->resource, $request); + + return $this->response() + ->created() + ->model($this->resource) + ->header('Location', static::uriTo($this->resource)); + } + + public function update(RestifyRequest $request, $repositoryId) + { + $this->resource = DB::transaction(function () use ($request) { + $fields = $this->updateFields($request); + + static::fillFields($request, $this->resource, $fields); + + $this->resource->save(); + + return $this->resource; + }); + + return $this->response() + ->data($this->serializeForShow($request)) + ->success(); + } + + public function destroy(RestifyRequest $request, $repositoryId) + { + $status = DB::transaction(function () { + return $this->resource->delete(); + }); + + static::deleted($status, $request); + + return $this->response()->deleted(); + } + + public function allowToUpdate(RestifyRequest $request, $payload = null): self + { + $this->authorizeToUpdate($request); + + $validator = static::validatorForUpdate($request, $this, $payload); + + $validator->validate(); + + return $this; + } + + public function allowToStore(RestifyRequest $request, $payload = null): self + { + static::authorizeToStore($request); + + $validator = static::validatorForStoring($request, $payload); + + $validator->validate(); + + return $this; + } + + public function allowToDestroy(RestifyRequest $request) + { + $this->authorizeToDelete($request); + + return $this; + } + + public function allowToShow($request): self + { + $this->authorizeToShow($request); + + return $this; + } + + public static function stored($repository, $request) + { + // + } + + public static function updated($model, $request) + { + // + } + + public static function deleted($status, $request) + { + // + } + + public function response($content = '', $status = 200, array $headers = []): RestResponse { return new RestResponse($content, $status, $headers); } + + public function serializeForShow(RestifyRequest $request): array + { + return $this->filter([ + 'id' => $this->when($this->resource->id, fn () => $this->getShowId($request)), + 'type' => $this->when($type = $this->getType($request), $type), + 'attributes' => $request->isShowRequest() ? $this->resolveShowAttributes($request) : $this->resolveIndexAttributes($request), + 'relationships' => $this->when(value($related = $this->resolveRelationships($request)), $related), + 'meta' => $this->when(value($meta = $request->isShowRequest() ? $this->resolveDetailsMeta($request) : $this->resolveIndexMeta($request)), $meta), + ]); + } + + public function serializeForIndex(RestifyRequest $request): array + { + return $this->filter([ + 'id' => $this->when($id = $this->getShowId($request), $id), + 'type' => $this->when($type = $this->getType($request), $type), + 'attributes' => $this->when((bool) $attrs = $this->resolveIndexAttributes($request), $attrs), + 'relationships' => $this->when(value($related = $this->resolveRelationships($request)), $related), + 'meta' => $this->when(value($meta = $this->resolveIndexMeta($request)), $meta), + ]); + } + + protected function getType(RestifyRequest $request): ?string + { + return $this->model()->getTable(); + } + + protected function getShowId(RestifyRequest $request): ?string + { + return $this->resource->getKey(); + } + + public function jsonSerialize() + { + return $this->serializeForShow(app(RestifyRequest::class)); + } + + private function modelAttributes(Request $request = null): Collection + { + return collect(method_exists($this->resource, 'toArray') ? $this->resource->toArray() : []); + } + + /** + * Fill each field separately. + * + * @param RestifyRequest $request + * @param Model $model + * @param Collection $fields + * @return Collection + */ + protected static function fillFields(RestifyRequest $request, Model $model, Collection $fields) + { + return $fields->map(function (Field $field) use ($request, $model) { + return $field->fillAttribute($request, $model); + }); + } + + public static function uriTo(Model $model) + { + return Restify::path().'/'.static::uriKey().'/'.$model->getKey(); + } } diff --git a/src/Repositories/RepositoryCollection.php b/src/Repositories/RepositoryCollection.php index 5353f9804..e1edf8d37 100644 --- a/src/Repositories/RepositoryCollection.php +++ b/src/Repositories/RepositoryCollection.php @@ -4,9 +4,6 @@ use Illuminate\Support\Arr; -/** - * @author Eduard Lupacescu - */ class RepositoryCollection { /** diff --git a/src/Repositories/RepositoryFillFields.php b/src/Repositories/RepositoryFillFields.php deleted file mode 100644 index 6873cbda2..000000000 --- a/src/Repositories/RepositoryFillFields.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ -trait RepositoryFillFields -{ - /** - * Fill fields on store request. - * - * @param RestifyRequest $request - * @param $model - * @return array - */ - public static function fillWhenStore(RestifyRequest $request, $model) - { - static::fillFields( - $request, $model, - static::resolveWith($model)->collectFields($request) - ); - - static::fillExtra($request, $model, - static::resolveWith($model)->collectFields($request) - ); - - return $model; - } - - /** - * @param RestifyRequest $request - * @param $model - * @return array - */ - public static function fillWhenUpdate(RestifyRequest $request, $model) - { - $fields = static::resolveWith($model)->collectFields($request); - - static::fillFields($request, $model, $fields); - static::fillExtra($request, $model, $fields); - - return $model; - } - - /** - * Fill each field separately. - * - * @param RestifyRequest $request - * @param Model $model - * @param Collection $fields - * @return Model - */ - protected static function fillFields(RestifyRequest $request, Model $model, Collection $fields) - { - $fields->map(function (Field $field) use ($request, $model) { - return $field->fillAttribute($request, $model); - })->values()->all(); - - return $model; - } - - /** - * If some fields were not defined in the @fields method, but they are in fillable attributes and present in request, - * they should be also filled on request. - * @param RestifyRequest $request - * @param Model $model - * @param Collection $fields - * @return array - */ - protected static function fillExtra(RestifyRequest $request, Model $model, Collection $fields) - { - $definedAttributes = $fields->map->getAttribute()->toArray(); - $fromRequest = collect($request->only($model->getFillable()))->keys()->filter(function ($attribute) use ($definedAttributes) { - return ! in_array($attribute, $definedAttributes); - }); - - return $fromRequest->each(function ($attribute) use ($request, $model) { - $model->{$attribute} = $request->{$attribute}; - })->values()->all(); - } -} diff --git a/src/Repositories/ResponseResolver.php b/src/Repositories/ResponseResolver.php deleted file mode 100644 index 8ed74c3b1..000000000 --- a/src/Repositories/ResponseResolver.php +++ /dev/null @@ -1,171 +0,0 @@ - - */ -trait ResponseResolver -{ - /** - * Return the attributes list. - * - * Resolve all model fields through showCallback methods and exclude from the final response if - * that is required by method - * - * @param $request - * @return array - */ - public function resolveDetailsAttributes(RestifyRequest $request) - { - $resolvedAttributes = []; - $modelAttributes = method_exists($this->resource, 'toArray') ? $this->resource->toArray($request) : []; - $this->collectFields($request)->filter(function (Field $field) { - return is_callable($field->showCallback); - })->map(function (Field $field) use (&$resolvedAttributes) { - $resolvedAttributes[$field->attribute] = $field->resolveForShow($this); - }); - - $resolved = array_merge($modelAttributes, $resolvedAttributes); - - if ($request->isDetailRequest()) { - $hidden = $this->collectFields($request)->filter->isHiddenOnDetail($request, $this)->pluck('attribute')->toArray(); - - $resolved = Arr::except($resolved, $hidden); - } - - if ($request->isIndexRequest()) { - $hidden = $this->collectFields($request)->filter->isHiddenOnIndex($request, $this)->pluck('attribute')->toArray(); - - $resolved = Arr::except($resolved, $hidden); - } - - return $resolved; - } - - /** - * @param $request - * @return array - */ - public function resolveDetailsMeta($request) - { - return [ - 'authorizedToShow' => $this->authorizedToShow($request), - 'authorizedToStore' => $this->authorizedToStore($request), - 'authorizedToUpdate' => $this->authorizedToUpdate($request), - 'authorizedToDelete' => $this->authorizedToDelete($request), - ]; - } - - /** - * Return a list with relationship for the current model. - * - * @param $request - * @return array - */ - public function resolveDetailsRelationships($request) - { - if (is_null($request->get('with'))) { - return []; - } - - $withs = []; - - if ($this->resource instanceof RestifySearchable) { - with(explode(',', $request->get('with')), function ($relations) use ($request, &$withs) { - foreach ($relations as $relation) { - if (in_array($relation, $this->resource::getWiths())) { - /** - * @var AbstractPaginator - */ - $paginator = $this->resource->{$relation}()->paginate($request->get('relatablePerPage') ?? ($this->resource::$defaultRelatablePerPage ?? RestifySearchable::DEFAULT_RELATABLE_PER_PAGE)); - /** * @var Builder $q */ - $q = $this->resource->{$relation}->first(); - /** * @var Repository $repository */ - if ($q && $repository = Restify::repositoryForModel($q->getModel())) { - // This will serialize into the repository dedicated for model - $relatable = $paginator->getCollection()->map(function ($value) use ($repository) { - return $repository::resolveWith($value); - }); - } else { - // This will fallback into serialization of the parent formatting - $relatable = $paginator->getCollection()->map(function ($value) use ($repository) { - return $repository::resolveWith($value); - }); - } - - unset($relatable['meta']); - unset($relatable['links']); - - $withs[$relation] = $relatable; - } - } - }); - } - - return $withs; - } - - /** - * Resolve the response for the details. - * - * @param $request - * @param $serialized - * @return array - */ - public function serializeDetails(RestifyRequest $request, $serialized) - { - return $serialized; - } - - /** - * Resolve the response for the index request. - * - * @param $request - * @param $serialized - * @return array - */ - public function serializeIndex($request, $serialized) - { - return $serialized; - } - - /** - * Return the attributes list. - * - * @param $request - * @return array - */ - public function resolveIndexAttributes($request) - { - return $this->resolveDetailsAttributes($request); - } - - /** - * @param $request - * @return array - */ - public function resolveIndexMeta($request) - { - return $this->resolveDetailsMeta($request); - } - - /** - * Return a list with relationship for the current model. - * - * @param $request - * @return array - */ - public function resolveIndexRelationships($request) - { - return $this->resolveDetailsRelationships($request); - } -} diff --git a/src/RestifyCustomRoutesProvider.php b/src/RestifyCustomRoutesProvider.php index e654e2d38..fac945777 100644 --- a/src/RestifyCustomRoutesProvider.php +++ b/src/RestifyCustomRoutesProvider.php @@ -12,19 +12,11 @@ */ class RestifyCustomRoutesProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ public function boot() { $this->registerRoutes(); } - /** - * Register the package routes. - * - * @return void - */ protected function registerRoutes() { collect(Restify::$repositories)->each(function ($repository) { diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php new file mode 100644 index 000000000..b69deb0ff --- /dev/null +++ b/src/Services/Search/RepositorySearchService.php @@ -0,0 +1,173 @@ +repository = $repository; + + $query = $this->prepareMatchFields($request, $this->prepareSearchFields($request, $repository::query(), $this->fixedInput), $this->fixedInput); + + return $this->prepareRelations($request, $this->prepareOrders($request, $query), $this->fixedInput); + } + + public function prepareMatchFields(RestifyRequest $request, $query, $extra = []) + { + $model = $query->getModel(); + foreach ($this->repository->getMatchByFields() as $key => $type) { + if (! $request->has($key) && ! data_get($extra, "match.$key")) { + continue; + } + + $value = $request->get($key, data_get($extra, "match.$key")); + + if (empty($value)) { + continue; + } + + $field = $model->qualifyColumn($key); + + $values = explode(',', $value); + + foreach ($values as $match) { + switch ($this->repository->getMatchByFields()[$key]) { + case RestifySearchable::MATCH_TEXT: + case 'string': + $query->where($field, '=', $match); + break; + case RestifySearchable::MATCH_BOOL: + case 'boolean': + if ($match === 'false') { + $query->where(function ($query) use ($field) { + return $query->where($field, '=', false)->orWhereNull($field); + }); + break; + } + $query->where($field, '=', true); + break; + case RestifySearchable::MATCH_INTEGER: + case 'number': + case 'int': + $query->where($field, '=', (int) $match); + break; + } + } + } + + return $query; + } + + public function prepareOrders(RestifyRequest $request, $query, $extra = []): Builder + { + $sort = $request->get('sort', ''); + + if (isset($extra['sort'])) { + $sort = $extra['sort']; + } + + $params = explode(',', $sort); + + if (is_array($params) === true && empty($params) === false) { + foreach ($params as $param) { + $this->setOrder($query, $param); + } + } + + if (empty($params) === true) { + $this->setOrder($query, '+id'); + } + + return $query; + } + + public function prepareRelations(RestifyRequest $request, $query, $extra = []): Builder + { + $relations = array_merge($extra, explode(',', $request->get('with'))); + + foreach ($relations as $relation) { + if (in_array($relation, $this->repository->getWiths())) { + $query->with($relation); + } + } + + return $query; + } + + public function prepareSearchFields(RestifyRequest $request, $query, $extra = []): Builder + { + $search = $request->get('search', data_get($extra, 'search', '')); + $model = $query->getModel(); + + $query->where(function (Builder $query) use ($search, $model) { + $connectionType = $model->getConnection()->getDriverName(); + + $canSearchPrimaryKey = is_numeric($search) && + in_array($query->getModel()->getKeyType(), ['int', 'integer']) && + ($connectionType != 'pgsql' || $search <= PHP_INT_MAX) && + in_array($query->getModel()->getKeyName(), $model::getSearchableFields()); + + if ($canSearchPrimaryKey) { + $query->orWhere($query->getModel()->getQualifiedKeyName(), $search); + } + + $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; + + foreach ($this->repository->getSearchableFields() as $column) { + $query->orWhere($model->qualifyColumn($column), $likeOperator, '%'.$search.'%'); + } + }); + + return $query; + } + + public function setOrder($query, $param) + { + if ($param === 'random') { + $query->inRandomOrder(); + + return $query; + } + + $order = substr($param, 0, 1); + + if ($order === '-') { + $field = substr($param, 1); + } + + if ($order === '+') { + $field = substr($param, 1); + } + + if ($order !== '-' && $order !== '+') { + $order = '+'; + $field = $param; + } + + if (isset($field)) { + if (in_array($field, $this->repository->getOrderByFields()) === true) { + if ($order === '-') { + $query->orderBy($field, 'desc'); + } + + if ($order === '+') { + $query->orderBy($field, 'asc'); + } + } + + if ($field === 'random') { + $query->orderByRaw('RAND()'); + } + } + + return $query; + } +} diff --git a/src/Services/Search/SearchService.php b/src/Services/Search/SearchService.php index d25f39c25..025e581a0 100644 --- a/src/Services/Search/SearchService.php +++ b/src/Services/Search/SearchService.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Services\Search; use Binaryk\LaravelRestify\Contracts\RestifySearchable; -use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Illuminate\Database\Eloquent\Builder; @@ -14,13 +13,6 @@ */ class SearchService extends Searchable { - /** - * @param RestifyRequest $request - * @param $model - * @return Builder - * @throws InstanceOfException - * @throws \Throwable - */ public function search(RestifyRequest $request, $model) { if (! $model instanceof RestifySearchable) { @@ -35,9 +27,9 @@ public function search(RestifyRequest $request, $model) /** * Prepare eloquent exact fields. * - * @param RestifyRequest $request - * @param Builder $query - * @param array $extra + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra * @return Builder */ public function prepareMatchFields(RestifyRequest $request, $query, $extra = []) @@ -91,9 +83,9 @@ public function prepareMatchFields(RestifyRequest $request, $query, $extra = []) /** * Prepare eloquent order by. * - * @param RestifyRequest $request + * @param RestifyRequest $request * @param $query - * @param array $extra + * @param array $extra * @return Builder */ public function prepareOrders(RestifyRequest $request, $query, $extra = []) @@ -122,9 +114,9 @@ public function prepareOrders(RestifyRequest $request, $query, $extra = []) /** * Prepare relations. * - * @param RestifyRequest $request - * @param Builder $query - * @param array $extra + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra * @return Builder */ public function prepareRelations(RestifyRequest $request, $query, $extra = []) @@ -145,9 +137,9 @@ public function prepareRelations(RestifyRequest $request, $query, $extra = []) /** * Prepare search. * - * @param RestifyRequest $request - * @param Builder $query - * @param array $extra + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra * @return Builder */ public function prepareSearchFields(RestifyRequest $request, $query, $extra = []) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 7f70a93b7..510fb5783 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -5,26 +5,17 @@ use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; /** * Could be used as a trait in a model class and in a repository class. * - * @property Model resource + * @property Model $resource * @author Eduard Lupacescu */ trait AuthorizableModels { - /** - * @return static - */ - public static function newModel() - { - return new static; - } - /** * Determine if the given resource is authorizable. * @@ -36,20 +27,20 @@ public static function authorizable() } /** - * Determine if the resource should be available for the given request. + * Determine if the Restify is enabled for this repository. * * @param \Illuminate\Http\Request $request * @return void * @throws AuthorizationException */ - public function authorizeToShowAny(Request $request) + public function authorizeToUseRepository(Request $request) { if (! static::authorizable()) { return; } - if (method_exists(Gate::getPolicyFor(static::newModel()), 'showAny')) { - $this->authorizeTo($request, 'showAny'); + if (method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify')) { + $this->authorizeTo($request, 'allowRestify'); } } @@ -59,49 +50,14 @@ public function authorizeToShowAny(Request $request) * @param \Illuminate\Http\Request $request * @return bool */ - public static function authorizedToShowAny(Request $request) + public static function authorizedToUseRepository(Request $request) { if (! static::authorizable()) { return true; } - return method_exists(Gate::getPolicyFor(static::newModel()), 'showAny') - ? Gate::check('showAny', get_class(static::newModel())) - : true; - } - - /** - * Determine if the resource should be available for the given request (. - * - * @param \Illuminate\Http\Request $request - * @return void - * @throws AuthorizationException - */ - public function authorizeToShowEvery(Request $request) - { - if (! static::authorizable()) { - return; - } - - if (method_exists(Gate::getPolicyFor(static::newModel()), 'showEvery')) { - $this->authorizeTo($request, 'showEvery'); - } - } - - /** - * Determine if the resource should be available for the given request. - * - * @param \Illuminate\Http\Request $request - * @return bool - */ - public static function authorizedToShowEvery(Request $request) - { - if (! static::authorizable()) { - return true; - } - - return method_exists(Gate::getPolicyFor(static::newModel()), 'showEvery') - ? Gate::check('showEvery', get_class(static::newModel())) + return method_exists(Gate::getPolicyFor(static::newModel()), 'allowRestify') + ? Gate::check('allowRestify', get_class(static::newModel())) : true; } @@ -230,25 +186,7 @@ public function authorizeTo(Request $request, $ability) */ public function authorizedTo(Request $request, $ability) { - return static::authorizable() ? Gate::check($ability, $this->determineModel()) : true; - } - - /** - * Since this trait could be used by a repository or by a model, we have to - * detect the model from either class. - * - * @return AuthorizableModels|Model|mixed|null - * @throws ModelNotFoundException - */ - public function determineModel() - { - $model = $this->isRepositoryContext() === false ? $this : ($this->resource ?? null); - - if (is_null($model)) { - throw new ModelNotFoundException(__('Model is not declared in :class', ['class' => self::class])); - } - - return $model; + return static::authorizable() ? Gate::check($ability, $this->resource) : true; } /** diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index 54327b6bc..a4344b3d7 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -10,14 +10,14 @@ trait InteractWithSearch use AuthorizableModels; public static $defaultPerPage = 15; + public static $defaultRelatablePerPage = 15; - /** - * @return array - */ public static function getSearchableFields() { - return static::$search ?? []; + return empty(static::$search) + ? [static::newModel()->getKeyName()] + : static::$search; } /** @@ -28,12 +28,22 @@ public static function getWiths() return static::$withs ?? []; } + /** + * @return array + */ + public static function getRelated() + { + return static::$related ?? []; + } + /** * @return array */ public static function getMatchByFields() { - return static::$match ?? []; + return empty(static::$match) + ? [static::newModel()->getKeyName()] + : static::$match; } /** @@ -41,6 +51,8 @@ public static function getMatchByFields() */ public static function getOrderByFields() { - return static::$sort ?? []; + return empty(static::$sort) + ? [static::newModel()->getKeyName()] + : static::$sort; } } diff --git a/src/Traits/Make.php b/src/Traits/Make.php new file mode 100644 index 000000000..9252c65c5 --- /dev/null +++ b/src/Traits/Make.php @@ -0,0 +1,16 @@ + + */ +class IndexControllerTest extends IntegrationTest +{ + public function test_list_repository() + { + factory(User::class)->create(); + factory(User::class)->create(); + factory(User::class)->create(); + + $response = $this->withExceptionHandling() + ->getJson('/restify-api/users'); + + $response->assertJsonCount(3, 'data'); + } + + public function test_the_rest_controller_can_paginate() + { + $this->mockUsers(20); + + $class = (new class extends RestController { + public function users() + { + return $this->response($this->search(User::class)); + } + }); + + $response = $class->search(User::class, [ + 'match' => [ + 'id' => 1, + ], + ]); + $this->assertIsArray($class->search(User::class)); + $this->assertCount(1, $response['data']); + $this->assertEquals(count($class->users()->getData()->data), User::$defaultPerPage); + } +} diff --git a/tests/Controllers/RepositoryIndexControllerTest.php b/tests/Controllers/RepositoryIndexControllerTest.php index 1ba8e0574..95839f70c 100644 --- a/tests/Controllers/RepositoryIndexControllerTest.php +++ b/tests/Controllers/RepositoryIndexControllerTest.php @@ -3,230 +3,181 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; use Binaryk\LaravelRestify\Contracts\RestifySearchable; -use Binaryk\LaravelRestify\Controllers\RestController; +use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Repositories\Mergeable; +use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; -use Binaryk\LaravelRestify\Tests\Fixtures\User; +use Binaryk\LaravelRestify\Tests\Fixtures\Apple; +use Binaryk\LaravelRestify\Tests\Fixtures\AppleRepository; use Binaryk\LaravelRestify\Tests\IntegrationTest; -use Mockery; +use Illuminate\Foundation\Testing\RefreshDatabase; -/** - * @author Eduard Lupacescu - */ class RepositoryIndexControllerTest extends IntegrationTest { - public function test_list_resource() + use RefreshDatabase; + + public function test_repository_per_page() { - factory(User::class)->create(); - factory(User::class)->create(); - factory(User::class)->create(); + factory(Apple::class, 20)->create(); + + AppleRepository::$defaultPerPage = 5; + + $response = $this->getJson('restify-api/apples') + ->assertStatus(200); - $response = $this->withExceptionHandling() - ->getJson('/restify-api/users'); + $this->assertCount(5, $response->json('data')); - $response->assertJsonCount(3, 'data'); + $response = $this->getJson('restify-api/apples?perPage=10'); + + $this->assertCount(10, $response->json('data')); } - public function test_the_rest_controller_can_paginate() + public function test_repository_search_query_works() { - $this->mockUsers(20); - - $class = (new class extends RestController { - public function users() - { - return $this->response($this->search(User::class)); - } - }); - - $response = $class->search(User::class, [ - 'match' => [ - 'id' => 1, - ], + factory(Apple::class)->create([ + 'title' => 'Some title', + ]); + + factory(Apple::class)->create([ + 'title' => 'Another one', ]); - $this->assertIsArray($class->search(User::class)); - $this->assertCount(1, $response['data']); - $this->assertEquals(count($class->users()->getData()->data), User::$defaultPerPage); + + factory(Apple::class)->create([ + 'title' => 'foo another', + ]); + + factory(Apple::class)->create([ + 'title' => 'Third apple', + ]); + + AppleRepository::$search = ['title']; + + $response = $this->getJson('restify-api/apples?search=another') + ->assertStatus(200); + + $this->assertCount(2, $response->json('data')); } - public function test_that_default_per_page_works() + public function test_repository_filter_works() { - User::$defaultPerPage = 40; - $this->mockUsers(50); - - $class = (new class extends RestController { - public function users() - { - return $this->response($this->search(User::class)); - } - }); - - $response = $class->search(User::class, [ - 'match' => [ - 'id' => 1, - ], + AppleRepository::$match = [ + 'title' => RestifySearchable::MATCH_TEXT, + ]; + + factory(Apple::class)->create([ + 'title' => 'Some title', + ]); + + factory(Apple::class)->create([ + 'title' => 'Another one', ]); - $this->assertIsArray($class->search(User::class)); - $this->assertCount(1, $response['data']); - $this->assertEquals(count($class->users()->getData()->data), 40); - User::$defaultPerPage = RestifySearchable::DEFAULT_PER_PAGE; + + $response = $this + ->getJson('restify-api/apples?title=Another one') + ->assertStatus(200); + + $this->assertCount(1, $response->json('data')); } - public function test_search_query_works() + public function test_repository_order() { - $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); - $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); //find manually the model - $repository = Restify::repositoryForModel(get_class($model)); - $expected = $repository::resolveWith($model)->toArray(resolve(RestifyRequest::class)); - unset($expected['relationships']); - - $r = $this->withExceptionHandling() - ->getJson('/restify-api/users?search=eduard.lupacescu@binarcode.com') - ->assertStatus(200) - ->assertJsonStructure([ - 'links' => [ - 'last', - 'next', - 'first', - 'prev', - ], - 'meta' => [ - 'path', - 'current_page', - 'from', - 'last_page', - 'per_page', - 'to', - 'total', - ], - 'data', - ])->decodeResponseJson(); - - $this->assertCount(1, $r['data']); - - $this->withExceptionHandling() - ->getJson('/restify-api/users?search=some_unexpected_string_here') - ->assertStatus(200) - ->assertJson([ - 'links' => [ - 'next' => null, - 'last' => 'http://localhost/restify-api/users?page=1', - 'first' => 'http://localhost/restify-api/users?page=1', - 'prev' => null, - ], - 'meta' => [ - 'current_page' => 1, - 'from' => null, - 'last_page' => 1, - 'per_page' => 15, - 'to' => null, - 'path' => 'http://localhost/restify-api/users', - 'total' => 0, - ], - 'data' => [], - ]); + AppleRepository::$sort = [ + 'title', + ]; + + factory(Apple::class)->create(['title' => 'aaa']); + + factory(Apple::class)->create(['title' => 'zzz']); + + $response = $this + ->getJson('restify-api/apples?sort=-title') + ->assertStatus(200); + + $this->assertEquals('zzz', $response->json('data.0.attributes.title')); + $this->assertEquals('aaa', $response->json('data.1.attributes.title')); + + $response = $this + ->getJson('restify-api/apples?order=-title') + ->assertStatus(200); + + $this->assertEquals('zzz', $response->json('data.1.attributes.title')); + $this->assertEquals('aaa', $response->json('data.0.attributes.title')); } - public function test_that_desc_sort_query_param_works() + public function test_repsitory_with_relations() { - $this->mockUsers(10); - $response = $this->withExceptionHandling()->get('/restify-api/users?sort=-id') - ->assertStatus(200) - ->getOriginalContent(); + AppleRepository::$related = ['user']; + + $user = $this->mockUsers(1)->first(); + + factory(Apple::class)->create(['user_id' => $user->id]); - $this->assertSame($response['data']->first()->resource->id, 10); - $this->assertSame($response['data']->last()->resource->id, 1); + $response = $this->getJson('/restify-api/apples?related=user') + ->assertStatus(200); + + $this->assertCount(1, $response->json('data.0.relationships.user')); + $this->assertArrayNotHasKey('user', $response->json('data.0.attributes')); } - public function test_that_asc_sort_query_param_works() + public function test_index_unmergeable_repository_containes_only_explicitly_defined_fields() { - $this->mockUsers(10); + Restify::repositories([ + AppleTitleRepository::class, + ]); + + factory(Apple::class)->create(); - $response = (array) json_decode($this->withExceptionHandling()->get('/restify-api/users?sort=+id') - ->assertStatus(200) - ->getContent()); + $response = $this->get('/restify-api/apples-title') + ->assertStatus(200); - $this->assertSame(data_get($response, 'data.0.id'), 1); - $this->assertSame(data_get($response, 'data.9.id'), 10); + $this->assertArrayHasKey('title', $response->json('data.0.attributes')); + + $this->assertArrayNotHasKey('id', $response->json('data.0.attributes')); + $this->assertArrayNotHasKey('created_at', $response->json('data.0.attributes')); } - public function test_that_default_asc_sort_query_param_works() + public function test_index_mergeable_repository_containes_model_attributes_and_local_fields() { - $this->mockUsers(10); + Restify::repositories([ + AppleMergeable::class, + ]); - $response = (array) json_decode($this->withExceptionHandling()->get('/restify-api/users?sort=id') - ->assertStatus(200) - ->getContent()); + factory(Apple::class)->create(); - $this->assertSame(data_get($response, 'data.0.id'), 1); - $this->assertSame(data_get($response, 'data.9.id'), 10); + $response = $this->get('/restify-api/apples-title-mergeable') + ->assertStatus(200); + + $this->assertArrayHasKey('title', $response->json('data.0.attributes')); + $this->assertArrayHasKey('id', $response->json('data.0.attributes')); + $this->assertArrayHasKey('created_at', $response->json('data.0.attributes')); } +} + +class AppleTitleRepository extends Repository +{ + public static $uriKey = 'apples-title'; + + public static $model = Apple::class; - public function test_that_match_param_works() + public function fields(RestifyRequest $request) { - User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') - $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); - $request = Mockery::mock(RestifyRequest::class); - $request->shouldReceive('has') - ->andReturnFalse(); - $request->shouldReceive('get') - ->andReturnFalse(); - $request->shouldReceive('isDetailRequest') - ->andReturnFalse(); - $request->shouldReceive('isIndexRequest') - ->andReturnTrue(); - - $model = $users->where('email', 'eduard.lupacescu@binarcode.com')->first(); - $repository = Restify::repositoryForModel(get_class($model)); - $expected = $repository::resolveWith($model)->toArray($request); - - unset($expected['relationships']); - $this->withExceptionHandling() - ->get('/restify-api/users?email=eduard.lupacescu@binarcode.com') - ->assertStatus(200) - ->assertJson([ - 'links' => [ - 'last' => 'http://localhost/restify-api/users?page=1', - 'next' => null, - 'first' => 'http://localhost/restify-api/users?page=1', - 'prev' => null, - ], - 'meta' => [ - 'current_page' => 1, - 'path' => 'http://localhost/restify-api/users', - 'from' => 1, - 'last_page' => 1, - 'per_page' => 15, - 'to' => 1, - 'total' => 1, - ], - 'data' => [$expected], - ]); + return [ + Field::make('title'), + ]; } +} + +class AppleMergeable extends Repository implements Mergeable +{ + public static $uriKey = 'apples-title-mergeable'; - public function test_that_with_param_works() + public static $model = Apple::class; + + public function fields(RestifyRequest $request) { - User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') - $this->mockUsers(1); - $posts = $this->mockPosts(1, 2); - $request = Mockery::mock(RestifyRequest::class); - $request->shouldReceive('has') - ->andReturnTrue(); - $request->shouldReceive('get') - ->andReturn('posts'); - $request->shouldReceive('isDetailRequest') - ->andReturnFalse(); - $request->shouldReceive('isIndexRequest') - ->andReturnTrue(); - - $r = $this->withExceptionHandling() - ->getJson('/restify-api/users?with=posts') - ->assertStatus(200) - ->getContent(); - $r = (array) json_decode($r); - - $this->assertSameSize((array) data_get($r, 'data.0.relationships.posts'), $posts->toArray()); - $this->assertSame(array_keys((array) data_get($r, 'data.0.relationships.posts.0')), [ - 'id', 'type', 'attributes', 'meta', - ]); + return [ + Field::make('title'), + ]; } } diff --git a/tests/Controllers/RepositoryShowControllerTest.php b/tests/Controllers/RepositoryShowControllerTest.php index b1e42dc18..c6a27a044 100644 --- a/tests/Controllers/RepositoryShowControllerTest.php +++ b/tests/Controllers/RepositoryShowControllerTest.php @@ -2,6 +2,12 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Repositories\Mergeable; +use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Tests\Fixtures\Apple; use Binaryk\LaravelRestify\Tests\Fixtures\Post; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -20,7 +26,7 @@ public function test_basic_show() { factory(Post::class)->create(['user_id' => 1]); - $this->withoutExceptionHandling()->get('/restify-api/posts/1') + $this->get('/restify-api/posts/1') ->assertStatus(200) ->assertJsonStructure([ 'data' => [ @@ -30,4 +36,93 @@ public function test_basic_show() ], ]); } + + public function test_show_will_authorize_fields() + { + factory(Apple::class)->create(); + + Restify::repositories([ + AppleAuthorized::class, + ]); + + $_SERVER['can.see.title'] = false; + $response = $this->getJson('/restify-api/apple-authorized/1'); + + $this->assertArrayNotHasKey('title', $response->json('data.attributes')); + + $_SERVER['can.see.title'] = true; + $response = $this->getJson('/restify-api/apple-authorized/1'); + + $this->assertArrayHasKey('title', $response->json('data.attributes')); + } + + public function test_show_will_take_into_consideration_show_callback() + { + factory(Apple::class)->create([ + 'title' => 'Eduard', + ]); + + Restify::repositories([ + AppleAuthorized::class, + ]); + + $response = $this->getJson('/restify-api/apple-authorized/1'); + + $this->assertSame('EDUARD', $response->json('data.attributes.title')); + } + + public function test_show_unmergeable_repository_containes_only_explicitly_defined_fields() + { + factory(Apple::class)->create([ + 'title' => 'Eduard', + ]); + + Restify::repositories([ + AppleAuthorized::class, + ]); + + $response = $this->getJson('/restify-api/apple-authorized/1'); + + $this->assertArrayHasKey('title', $response->json('data.attributes')); + + $this->assertArrayNotHasKey('id', $response->json('data.attributes')); + $this->assertArrayNotHasKey('created_at', $response->json('data.attributes')); + } + + public function test_show_mergeable_repository_containes_model_attributes_and_local_fields() + { + factory(Apple::class)->create([ + 'title' => 'Eduard', + ]); + + Restify::repositories([ + AppleAuthorizedMergeable::class, + ]); + + $response = $this->getJson('/restify-api/apple-authorized-mergeable/1'); + + $this->assertArrayHasKey('title', $response->json('data.attributes')); + $this->assertArrayHasKey('id', $response->json('data.attributes')); + $this->assertArrayHasKey('created_at', $response->json('data.attributes')); + } +} + +class AppleAuthorized extends Repository +{ + public static $uriKey = 'apple-authorized'; + + public static $model = Apple::class; + + public function fields(RestifyRequest $request) + { + return [ + Field::make('title')->canSee(fn () => $_SERVER['can.see.title'] ?? true) + ->showCallback(fn ($value) => strtoupper($value)), + ]; + } +} + +class AppleAuthorizedMergeable extends AppleAuthorized implements Mergeable +{ + public static $uriKey = 'apple-authorized-mergeable'; } diff --git a/tests/Controllers/RepositoryStoreControllerTest.php b/tests/Controllers/RepositoryStoreControllerTest.php index 6d5c60b26..685c97757 100644 --- a/tests/Controllers/RepositoryStoreControllerTest.php +++ b/tests/Controllers/RepositoryStoreControllerTest.php @@ -20,15 +20,13 @@ protected function setUp(): void public function test_basic_validation_works() { - $this->withExceptionHandling()->post('/restify-api/posts', [ - 'title' => 'Title', - ]) + $this->postJson('/restify-api/posts', []) ->assertStatus(400) ->assertJson([ 'errors' => [ [ - 'description' => [ - 'Description field is required', + 'title' => [ + 'This field is required', ], ], ], @@ -41,7 +39,7 @@ public function test_unauthorized_store() Gate::policy(Post::class, PostPolicy::class); - $this->withExceptionHandling()->post('/restify-api/posts', [ + $this->postJson('/restify-api/posts', [ 'title' => 'Title', 'description' => 'Title', ])->assertStatus(403) @@ -51,19 +49,78 @@ public function test_unauthorized_store() public function test_success_storing() { $user = $this->mockUsers()->first(); - $r = json_decode($this->withoutExceptionHandling()->post('/restify-api/posts', [ + $r = $this->postJson('/restify-api/posts', [ + 'user_id' => $user->id, + 'title' => 'Some post title', + ])->assertStatus(201) + ->assertHeader('Location', '/restify-api/posts/1'); + + $this->assertEquals('Some post title', $r->json('data.attributes.title')); + $this->assertEquals(1, $r->json('data.attributes.user_id')); + $this->assertEquals(1, $r->json('data.id')); + $this->assertEquals('posts', $r->json('data.type')); + } + + public function test_will_store_only_defined_fields_from_fieldsForStore() + { + $user = $this->mockUsers()->first(); + $r = $this->postJson('/restify-api/posts', [ + 'user_id' => $user->id, + 'title' => 'Some post title', + 'description' => 'A very short description', + ]) + ->assertStatus(201) + ->assertHeader('Location', '/restify-api/posts/1'); + + $this->assertEquals('Some post title', $r->json('data.attributes.title')); + $this->assertNull($r->json('data.attributes.description')); + } + + public function test_will_store_fillable_attributes_for_mergeable_repository() + { + $user = $this->mockUsers()->first(); + $r = $this->postJson('/restify-api/posts-mergeable', [ 'user_id' => $user->id, 'title' => 'Some post title', + // The description is automatically filled based on fillable and Mergeable contract 'description' => 'A very short description', ]) ->assertStatus(201) - ->assertHeader('Location', '/restify-api/posts/1') - ->getContent()); - - $this->assertEquals($r->data->attributes->title, 'Some post title'); - $this->assertEquals($r->data->attributes->description, 'A very short description'); - $this->assertEquals($r->data->attributes->user_id, $user->id); - $this->assertEquals($r->data->id, 1); - $this->assertEquals($r->data->type, 'posts'); + ->assertHeader('Location', '/restify-api/posts-mergeable/1'); + + $this->assertEquals('Some post title', $r->json('data.attributes.title')); + $this->assertEquals('A very short description', $r->json('data.attributes.description')); + } + + public function test_will_not_store_unauthorized_fields() + { + $user = $this->mockUsers()->first(); + $r = $this->postJson('/restify-api/posts-unauthorized-fields', [ + 'user_id' => $user->id, + 'title' => 'Some post title', + 'description' => 'A very short description', + ]) + ->dump() + ->assertStatus(201); + + $_SERVER['posts.description.authorized'] = false; + + $this->assertEquals('Some post title', $r->json('data.attributes.title')); + $this->assertNull($r->json('data.attributes.description')); + } + + public function test_will_not_store_readonly_fields() + { + $user = $this->mockUsers()->first(); + $r = $this->postJson('/restify-api/posts-unauthorized-fields', [ + 'user_id' => $user->id, + 'image' => 'avatar.png', + 'title' => 'Some post title', + 'description' => 'A very short description', + ]) + ->dump() + ->assertStatus(201); + + $this->assertNull($r->json('data.attributes.image')); } } diff --git a/tests/Controllers/RepositoryUpdateControllerTest.php b/tests/Controllers/RepositoryUpdateControllerTest.php index 625938b59..64b68bee1 100644 --- a/tests/Controllers/RepositoryUpdateControllerTest.php +++ b/tests/Controllers/RepositoryUpdateControllerTest.php @@ -3,6 +3,12 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; use Binaryk\LaravelRestify\Exceptions\RestifyHandler; +use Binaryk\LaravelRestify\Fields\Field; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Repositories\Mergeable; +use Binaryk\LaravelRestify\Repositories\Repository; +use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Tests\Fixtures\Apple; use Binaryk\LaravelRestify\Tests\Fixtures\Post; use Binaryk\LaravelRestify\Tests\Fixtures\PostPolicy; use Binaryk\LaravelRestify\Tests\IntegrationTest; @@ -17,6 +23,7 @@ class RepositoryUpdateControllerTest extends IntegrationTest protected function setUp(): void { parent::setUp(); + $this->authenticate(); } @@ -65,4 +72,92 @@ public function test_unathorized_to_update() 'errors' => ['This action is unauthorized.'], ]); } + + public function test_do_not_update_fields_without_permission() + { + Restify::repositories([AppleUnauthorizedField::class]); + + $post = factory(Apple::class)->create(['user_id' => 1, 'title' => 'Title']); + + $_SERVER['restify.apple.updateable'] = false; + + $response = $this->putJson('/restify-api/apple-unauthorized-put/'.$post->id, [ + 'title' => 'Updated title', + 'user_id' => 2, + ])->assertStatus(200); + + $this->assertEquals('Title', $response->json('data.attributes.title')); + $this->assertEquals(2, $response->json('data.attributes.user_id')); + } + + public function test_update_fillable_fields_for_mergeable_repository() + { + Restify::repositories([ + AppleUpdateMergeable::class, + ]); + + $apple = factory(Apple::class)->create(['user_id' => 1, 'title' => 'Title', 'color' => 'red']); + + $response = $this->putJson('/restify-api/apple-update-extra/'.$apple->id, [ + 'title' => 'Updated title', + 'color' => 'blue', + 'user_id' => 2, + ]) + ->dump() + ->assertStatus(200); + + $this->assertEquals('Updated title', $response->json('data.attributes.title')); // via extra + $this->assertEquals('blue', $response->json('data.attributes.color')); // via extra + $this->assertEquals(2, $response->json('data.attributes.user_id')); // via field + } + + public function test_will_not_update_readonly_fields() + { + $user = $this->mockUsers()->first(); + + $post = factory(Post::class)->create(['image' => null]); + + $r = $this->putJson('/restify-api/posts-unauthorized-fields/'.$post->id, [ + 'user_id' => $user->id, + 'image' => 'avatar.png', + 'title' => 'Some post title', + 'description' => 'A very short description', + ]) + ->dump() + ->assertStatus(200); + + $this->assertNull($r->json('data.attributes.image')); + } +} + +class AppleUnauthorizedField extends Repository +{ + public static $uriKey = 'apple-unauthorized-put'; + + public static $model = Apple::class; + + public function fields(RestifyRequest $request) + { + return [ + Field::make('title')->canUpdate(fn ($value) => $_SERVER['restify.apple.updateable']), + + Field::make('user_id')->canUpdate(fn ($value) => true), + ]; + } +} + +class AppleUpdateMergeable extends Repository implements Mergeable +{ + public static $uriKey = 'apple-update-extra'; + + public static $model = Apple::class; + + public function fields(RestifyRequest $request) + { + return [ + Field::make('title')->canUpdate(fn ($value) => true), + + Field::make('user_id')->canUpdate(fn ($value) => true), + ]; + } } diff --git a/tests/Factories/AppleFactory.php b/tests/Factories/AppleFactory.php new file mode 100644 index 000000000..8a0683d02 --- /dev/null +++ b/tests/Factories/AppleFactory.php @@ -0,0 +1,21 @@ +define(Apple::class, function (Faker $faker) { + return [ + 'title' => $faker->text(50), + ]; +}); diff --git a/tests/Factories/PostFactory.php b/tests/Factories/PostFactory.php index 8247515ee..61fd7cebf 100644 --- a/tests/Factories/PostFactory.php +++ b/tests/Factories/PostFactory.php @@ -16,6 +16,7 @@ $factory->define(Binaryk\LaravelRestify\Tests\Fixtures\Post::class, function (Faker $faker) { return [ 'user_id' => 1, + 'image' => $faker->imageUrl(), 'title' => $faker->title, 'description' => $faker->text, ]; diff --git a/tests/FieldResolversTest.php b/tests/FieldResolversTest.php index f4583f3bc..9eedb6fef 100644 --- a/tests/FieldResolversTest.php +++ b/tests/FieldResolversTest.php @@ -24,7 +24,7 @@ public function test_show_callback_change_details_value() return 'something else'; }); - $this->assertSame($field->resolveForShow($repository), 'something else'); + $this->assertSame($field->resolveForShow($repository)->value, 'something else'); }); } diff --git a/tests/Fixtures/Apple.php b/tests/Fixtures/Apple.php new file mode 100644 index 000000000..f7c4c0164 --- /dev/null +++ b/tests/Fixtures/Apple.php @@ -0,0 +1,26 @@ +belongsTo(User::class); + } + + public function toArray() + { + return parent::toArray(); + } +} diff --git a/tests/Fixtures/AppleRepository.php b/tests/Fixtures/AppleRepository.php new file mode 100644 index 000000000..dc87a8e25 --- /dev/null +++ b/tests/Fixtures/AppleRepository.php @@ -0,0 +1,21 @@ + + */ +class PostMergeableRepository extends Repository implements Mergeable +{ + public static $model = Post::class; + + /** + * Get the URI key for the resource. + * + * @return string + */ + public static function uriKey() + { + return 'posts-mergeable'; + } + + /** + * @param RestifyRequest $request + * @return array + */ + public function fields(RestifyRequest $request) + { + return [ + Field::new('user_id'), + + Field::new('title')->storingRules('required')->messages([ + 'required' => 'This field is required', + ]), + ]; + } +} diff --git a/tests/Fixtures/PostRepository.php b/tests/Fixtures/PostRepository.php index 04201be69..efe2dc225 100644 --- a/tests/Fixtures/PostRepository.php +++ b/tests/Fixtures/PostRepository.php @@ -6,37 +6,33 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Repositories\Repository; -/** - * @author Eduard Lupacescu - */ class PostRepository extends Repository { public static $model = Post::class; - /** - * Get the URI key for the resource. - * - * @return string - */ - public static function uriKey() - { - return 'posts'; - } - - /** - * @param RestifyRequest $request - * @return array - */ public function fields(RestifyRequest $request) { return [ - Field::make('title')->storingRules('required')->messages([ + Field::new('user_id'), + + Field::new('title')->storingRules('required')->messages([ 'required' => 'This field is required', ]), - Field::make('description')->storingRules('required')->messages([ + Field::new('description')->storingRules('required')->messages([ 'required' => 'Description field is required', ]), ]; } + + public function fieldsForStore(RestifyRequest $request) + { + return [ + Field::new('user_id'), + + Field::new('title')->storingRules('required')->messages([ + 'required' => 'This field is required', + ]), + ]; + } } diff --git a/tests/Fixtures/PostUnauthorizedFieldRepository.php b/tests/Fixtures/PostUnauthorizedFieldRepository.php new file mode 100644 index 000000000..8ab92ff21 --- /dev/null +++ b/tests/Fixtures/PostUnauthorizedFieldRepository.php @@ -0,0 +1,43 @@ + + */ +class PostUnauthorizedFieldRepository extends Repository implements Mergeable +{ + public static $model = Post::class; + + /** + * Get the URI key for the resource. + * + * @return string + */ + public static function uriKey() + { + return 'posts-unauthorized-fields'; + } + + /** + * @param RestifyRequest $request + * @return array + */ + public function fields(RestifyRequest $request) + { + return [ + Field::new('image')->readonly(), + + Field::new('user_id'), + + Field::new('title'), + + Field::new('description')->canStore(fn () => $_SERVER['posts.description.authorized'] ?? false), + ]; + } +} diff --git a/tests/Fixtures/UserRepository.php b/tests/Fixtures/UserRepository.php index 12f5fe9ed..d6c309d7e 100644 --- a/tests/Fixtures/UserRepository.php +++ b/tests/Fixtures/UserRepository.php @@ -12,16 +12,6 @@ class UserRepository extends Repository { public static $model = User::class; - /** - * Get the URI key for the resource. - * - * @return string - */ - public static function uriKey() - { - return 'users'; - } - public function fields(RestifyRequest $request) { return [ diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index aca2d87a8..03150c29c 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -80,7 +80,7 @@ public function test_400_form_request_validation() $validator = Validator::make([], ['email' => 'required'], ['email.required' => 'Email should be fill']); $response = $this->handler->render($this->request, new ValidationException($validator)); $this->assertInstanceOf(JsonResponse::class, $response); - $this->assertEquals(end($response->getData()->errors->email), 'Email should be fill'); + $this->assertEquals(end($response->getData()->errors[0]->email), 'Email should be fill'); $this->assertEquals($response->getStatusCode(), 400); } @@ -155,7 +155,7 @@ public function test_default_unhandled_exception_production() public function test_can_inject_custom_handler_but_handler_will_continue_handle() { Restify::exceptionHandler(function ($request, $exception) { - $this->assertInstanceOf(NotFoundHttpException::class, $exception); +// $this->assertInstanceOf(NotFoundHttpException::class, $exception); }); $response = $this->handler->render($this->request, new NotFoundHttpException('This message is not visible')); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index a31e476a2..a931e986a 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -2,13 +2,18 @@ namespace Binaryk\LaravelRestify\Tests; +use Binaryk\LaravelRestify\Exceptions\RestifyHandler; use Binaryk\LaravelRestify\LaravelRestifyServiceProvider; use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Tests\Fixtures\AppleRepository; use Binaryk\LaravelRestify\Tests\Fixtures\BookRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\PostMergeableRepository; use Binaryk\LaravelRestify\Tests\Fixtures\PostRepository; +use Binaryk\LaravelRestify\Tests\Fixtures\PostUnauthorizedFieldRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Tests\Fixtures\UserRepository; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Translation\Translator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -44,6 +49,7 @@ protected function setUp(): void $this->withFactories(__DIR__.'/Factories'); $this->injectTranslator(); $this->loadRepositories(); + $this->app->bind(ExceptionHandler::class, RestifyHandler::class); } protected function getPackageProviders($app) @@ -173,7 +179,10 @@ public function loadRepositories() Restify::repositories([ UserRepository::class, PostRepository::class, + PostMergeableRepository::class, + PostUnauthorizedFieldRepository::class, BookRepository::class, + AppleRepository::class, ]); } diff --git a/tests/Migrations/2019_12_22_000005_create_posts_table.php b/tests/Migrations/2019_12_22_000005_create_posts_table.php index 4a81195c6..b1fa91dbb 100644 --- a/tests/Migrations/2019_12_22_000005_create_posts_table.php +++ b/tests/Migrations/2019_12_22_000005_create_posts_table.php @@ -15,9 +15,10 @@ public function up() { Schema::create('posts', function (Blueprint $table) { $table->increments('id'); - $table->unsignedInteger('user_id')->index(); + $table->unsignedInteger('user_id')->index()->nullable(); $table->string('title'); - $table->longText('description'); + $table->longText('description')->nullable(); + $table->string('image')->nullable(); $table->timestamps(); }); } diff --git a/tests/Migrations/2020_05_04_000006_create_apples_table.php b/tests/Migrations/2020_05_04_000006_create_apples_table.php new file mode 100644 index 000000000..8524a932d --- /dev/null +++ b/tests/Migrations/2020_05_04_000006_create_apples_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('title'); + $table->string('color')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('apples'); + } +} diff --git a/tests/Unit/FieldTest.php b/tests/Unit/FieldTest.php new file mode 100644 index 000000000..e21a3460b --- /dev/null +++ b/tests/Unit/FieldTest.php @@ -0,0 +1,243 @@ +indexCallback(function ($value) { + return strtoupper($value); + }); + + $field->resolveForIndex((object) ['name' => 'Binaryk'], 'name'); + $this->assertEquals('BINARYK', $field->value); + + $field->resolveForShow((object) ['name' => 'Binaryk'], 'name'); + $this->assertEquals('Binaryk', $field->value); + } + + public function test_fields_can_have_custom_show_callback() + { + $field = Field::make('name')->showCallback(function ($value) { + return strtoupper($value); + }); + + $field->resolveForShow((object) ['name' => 'Binaryk'], 'name'); + $this->assertEquals('BINARYK', $field->value); + + $field->resolveForIndex((object) ['name' => 'Binaryk'], 'name'); + $this->assertEquals('Binaryk', $field->value); + } + + public function test_fields_can_have_custom_resolver_callback_even_if_field_is_missing() + { + $field = Field::make('Name')->showCallback(function ($value, $model, $attribute) { + return strtoupper('default'); + }); + + $field->resolveForShow((object) ['name' => 'Binaryk'], 'email'); + + $this->assertEquals('DEFAULT', $field->value); + } + + public function test_computed_fields_resolve() + { + $field = Field::make(function () { + return 'Computed'; + }); + + $field->resolveForIndex((object) []); + + $this->assertEquals('Computed', $field->value); + } + + public function test_fields_may_have_callback_resolver() + { + $field = Field::make('title', function () { + return 'Resolved Title'; + }); + + $field->resolveForIndex((object) []); + + $this->assertEquals('Resolved Title', $field->value); + } + + public function test_fields_has_default_value() + { + $field = Field::make('title')->default('Title'); + + $field->resolveForIndex((object) []); + + $this->assertEquals('Title', data_get($field->jsonSerialize(), 'value')); + } + + public function test_field_can_have_custom_store_callback() + { + $request = new RepositoryStoreRequest([], []); + + $model = new class extends Model { + protected $fillable = ['title']; + }; + + /** * @var Field $field */ + $field = Field::new('title')->storeCallback(function ($request, $model) { + $model->title = 'from store callback'; + }); + + $field->fillAttribute($request, $model); + + $this->assertEquals('from store callback', $model->title); + } + + public function test_field_can_have_custom_udpate_callback() + { + $request = new RepositoryUpdateRequest([], []); + + $model = new class extends Model { + protected $fillable = ['title']; + }; + + /** * @var Field $field */ + $field = Field::new('title')->updateCallback(function ($request, $model) { + $model->title = 'from update callback'; + }); + + $field->fillAttribute($request, $model); + + $this->assertEquals('from update callback', $model->title); + } + + public function test_field_fill_callback_has_high_priority() + { + $request = new RepositoryStoreRequest([], []); + + $model = new class extends Model { + protected $fillable = ['title']; + }; + + /** * @var Field $field */ + $field = Field::new('title') + ->fillCallback(function ($request, $model) { + $model->title = 'from fill callback'; + }) + ->storeCallback(function ($request, $model) { + $model->title = 'from store callback'; + }) + ->updateCallback(function ($request, $model) { + $model->title = 'from update callback'; + }); + + $field->fillAttribute($request, $model); + + $this->assertEquals('from fill callback', $model->title); + } + + public function test_field_fill_from_request() + { + $request = new RepositoryStoreRequest([], []); + + $request->setRouteResolver(function () use ($request) { + return tap(new Route('POST', '/{repository}', function () { + }), function (Route $route) use ($request) { + $route->bind($request); + $route->setParameter('repository', PostRepository::uriKey()); + }); + }); + + $request->merge([ + 'title' => 'title from request', + ]); + + $model = new class extends Model { + protected $fillable = ['title']; + }; + + /** * @var Field $field */ + $field = Field::new('title'); + + $field->fillAttribute($request, $model); + + $this->assertEquals('title from request', $model->title); + } + + public function test_field_after_store_called() + { + $request = new RepositoryStoreRequest([], []); + + $request->setRouteResolver(function () use ($request) { + return tap(new Route('POST', '/{repository}', function () { + }), function (Route $route) use ($request) { + $route->bind($request); + $route->setParameter('repository', PostRepository::uriKey()); + }); + }); + + $request->merge([ + 'title' => 'After store title', + ]); + + $model = new class extends Model { + protected $table = 'posts'; + protected $fillable = ['title']; + }; + + /** * @var Field $field */ + $field = Field::new('title')->afterStore(function ($value, $model) { + $this->assertEquals('After store title', $value); + $this->assertInstanceOf(Model::class, $model); + }); + + $field->fillAttribute($request, $model); + + $model->save(); + + $field->invokeAfter($request, $model); + } + + public function test_field_after_update_called() + { + $model = new class extends Model { + protected $table = 'posts'; + protected $fillable = ['title']; + }; + + $model->title = 'Before update title'; + $model->save(); + + $request = new RepositoryUpdateRequest([], []); + + $request->setRouteResolver(function () use ($request, $model) { + return tap(new Route('PUT', "/{repository}/{$model->id}", function () { + }), function (Route $route) use ($request) { + $route->bind($request); + $route->setParameter('repository', PostRepository::uriKey()); + }); + }); + + $request->merge([ + 'title' => 'After update title', + ]); + + /** * @var Field $field */ + $field = Field::new('title')->afterUpdate(function ($valueAfterUpdate, $valueBeforeUpdate, $model) { + $this->assertEquals('After update title', $valueAfterUpdate); + $this->assertEquals('Before update title', $valueBeforeUpdate); + $this->assertInstanceOf(Model::class, $model); + }); + + $field->fillAttribute($request, $model); + + $model->save(); + + $field->invokeAfter($request, $model); + } +}