From e88d86899ec702a766e02607fb29420605daea3a Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 3 Jul 2020 16:33:16 +0300 Subject: [PATCH 1/6] Store bulk endpoint --- routes/api.php | 2 + src/Commands/stubs/policy.stub | 11 ++ src/Fields/Field.php | 61 ++++++++-- src/Fields/FieldCollection.php | 7 ++ src/Fields/OrganicField.php | 7 ++ .../RepositoryStoreBulkController.php | 16 +++ .../Requests/RepositoryStoreBulkRequest.php | 15 +++ src/Http/Requests/RestifyRequest.php | 5 + src/Repositories/Repository.php | 113 ++++++++++++++---- src/Repositories/ValidatingTrait.php | 61 ++++++++-- src/Traits/AuthorizableModels.php | 16 +++ .../RepositoryStoreBulkControllerTest.php | 71 +++++++++++ tests/Fixtures/Post/PostPolicy.php | 8 ++ tests/Fixtures/Post/PostRepository.php | 11 ++ 14 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 src/Http/Controllers/RepositoryStoreBulkController.php create mode 100644 src/Http/Requests/RepositoryStoreBulkRequest.php create mode 100644 tests/Controllers/RepositoryStoreBulkControllerTest.php diff --git a/routes/api.php b/routes/api.php index dbfc496ce..8ff3772a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use Binaryk\LaravelRestify\Http\Controllers\RepositoryFilterController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController; +use Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController; use Illuminate\Support\Facades\Route; @@ -27,6 +28,7 @@ // API CRUD Route::get('/{repository}', '\\'.RepositoryIndexController::class); Route::post('/{repository}', '\\'.RepositoryStoreController::class); +Route::post('/{repository}/bulk', '\\'.RepositoryStoreBulkController::class); Route::get('/{repository}/{repositoryId}', '\\'.RepositoryShowController::class); Route::patch('/{repository}/{repositoryId}', '\\'.RepositoryUpdateController::class); Route::put('/{repository}/{repositoryId}', '\\'.RepositoryUpdateController::class); diff --git a/src/Commands/stubs/policy.stub b/src/Commands/stubs/policy.stub index 43746bc56..7e26d179e 100644 --- a/src/Commands/stubs/policy.stub +++ b/src/Commands/stubs/policy.stub @@ -44,6 +44,17 @@ class {{ class }} // } + /** + * Determine whether the user can create multiple models at once. + * + * @param \App\User $user + * @return mixed + */ + public function storeBulk(User $user) + { + // + } + /** * Determine whether the user can update the model. * diff --git a/src/Fields/Field.php b/src/Fields/Field.php index dcc5ad0e9..facbe820c 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -61,6 +61,12 @@ class Field extends OrganicField implements JsonSerializable */ public $storeCallback; + /** + * Callback called when the value is filled from a store bulk, this callback will do not override the fill action. + * @var Closure + */ + public $storeBulkCallback; + /** * Callback called when update. * @var Closure @@ -165,6 +171,13 @@ public function storeCallback(Closure $callback) return $this; } + public function storeCallbackCallback(Closure $callback) + { + $this->storeBulkCallback = $callback; + + return $this; + } + public function updateCallback(Closure $callback) { $this->updateCallback = $callback; @@ -191,9 +204,10 @@ public function fillCallback(Closure $callback) * * @param RestifyRequest $request * @param $model + * @param int|null $bulkRow * @return mixed|void */ - public function fillAttribute(RestifyRequest $request, $model) + public function fillAttribute(RestifyRequest $request, $model, int $bulkRow = null) { $this->resolveValueBeforeUpdate($request, $model); @@ -205,28 +219,34 @@ public function fillAttribute(RestifyRequest $request, $model) if (! $this->isHidden($request) && isset($this->fillCallback)) { return call_user_func( - $this->fillCallback, $request, $model, $this->attribute + $this->fillCallback, $request, $model, $this->attribute, $bulkRow ); } if (isset($this->appendCallback)) { - return $this->fillAttributeFromAppend($request, $model, $this->attribute); + return $this->fillAttributeFromAppend($request, $model, $this->attribute, $bulkRow); } if ($request->isStoreRequest() && is_callable($this->storeCallback)) { return call_user_func( - $this->storeCallback, $request, $model, $this->attribute + $this->storeCallback, $request, $model, $this->attribute, $bulkRow + ); + } + + if ($request->isStoreBulkRequest() && is_callable($this->storeBulkCallback)) { + return call_user_func( + $this->storeBulkCallback, $request, $model, $this->attribute, $bulkRow ); } if ($request->isUpdateRequest() && is_callable($this->updateCallback)) { return call_user_func( - $this->updateCallback, $request, $model, $this->attribute + $this->updateCallback, $request, $model, $this->attribute, $bulkRow ); } $this->fillAttributeFromRequest( - $request, $model, $this->attribute + $request, $model, $this->attribute, $bulkRow ); } @@ -236,11 +256,22 @@ public function fillAttribute(RestifyRequest $request, $model) * @param RestifyRequest $request * @param $model * @param $attribute + * @param int|null $bulkRow */ - protected function fillAttributeFromRequest(RestifyRequest $request, $model, $attribute) + protected function fillAttributeFromRequest(RestifyRequest $request, $model, $attribute, int $bulkRow = null) { - if ($request->exists($attribute) || $request->get($attribute)) { - $model->{$attribute} = $request[$attribute] ?? $request->get($attribute); + if (is_null($bulkRow)) { + if ($request->exists($attribute) || $request->input($attribute)) { + $model->{$attribute} = $request[$attribute] ?? $request->input($attribute); + } + + return; + } + + $bulkableAttribute = $bulkRow . '.' . $attribute; + + if ($request->exists($bulkableAttribute) || $request->get($bulkableAttribute)) { + $model->{$attribute} = $request[$bulkableAttribute] ?? $request->get($bulkableAttribute); } } @@ -286,6 +317,13 @@ public function storingRules($rules) return $this; } + public function storeBulkRules($rules) + { + $this->storingBulkRules = ($rules instanceof Rule || is_string($rules)) ? func_get_args() : $rules; + + return $this; + } + /** * Alias for storingRules - to maintain it consistent. * @@ -334,6 +372,11 @@ public function getStoringRules(): array return array_merge($this->rules, $this->storingRules); } + public function getStoringBulkRules(): array + { + return $this->storingBulkRules; + } + public function getUpdatingRules(): array { return array_merge($this->rules, $this->updatingRules); diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index 152afe843..f642323a2 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -57,6 +57,13 @@ public function forStore(RestifyRequest $request, $repository): self })->values(); } + public function forStoreBulk(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnStoreBulk($request, $repository); + })->values(); + } + public function forUpdate(RestifyRequest $request, $repository): self { return $this->filter(function (Field $field) use ($repository, $request) { diff --git a/src/Fields/OrganicField.php b/src/Fields/OrganicField.php index e41cfedfe..cc2ab1aae 100644 --- a/src/Fields/OrganicField.php +++ b/src/Fields/OrganicField.php @@ -22,6 +22,8 @@ abstract class OrganicField extends BaseField public array $storingRules = []; + public array $storingBulkRules = []; + public array $updatingRules = []; public array $messages = []; @@ -169,6 +171,11 @@ public function isShownOnStore(RestifyRequest $request, $repository): bool return ! $this->isReadonly($request); } + public function isShownOnStoreBulk(RestifyRequest $request, $repository): bool + { + return ! $this->isReadonly($request); + } + public function isHidden(RestifyRequest $request) { return with($this->hiddenCallback, function ($callback) use ($request) { diff --git a/src/Http/Controllers/RepositoryStoreBulkController.php b/src/Http/Controllers/RepositoryStoreBulkController.php new file mode 100644 index 000000000..526cbb606 --- /dev/null +++ b/src/Http/Controllers/RepositoryStoreBulkController.php @@ -0,0 +1,16 @@ +repository() + ->allowToBulkStore($request) + ->storeBulk($request); + } +} diff --git a/src/Http/Requests/RepositoryStoreBulkRequest.php b/src/Http/Requests/RepositoryStoreBulkRequest.php new file mode 100644 index 000000000..87a23ea70 --- /dev/null +++ b/src/Http/Requests/RepositoryStoreBulkRequest.php @@ -0,0 +1,15 @@ +all(), + ); + } +} diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 3b6c8c531..206f5ed71 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -58,6 +58,11 @@ public function isStoreRequest() return $this instanceof RepositoryStoreRequest; } + public function isStoreBulkRequest() + { + return $this instanceof RepositoryStoreBulkRequest; + } + public function isViaRepository() { return $this->viaRepository && $this->viaRepositoryId; diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 0c7bcd09a..09bd8295a 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -8,6 +8,7 @@ use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Fields\FieldCollection; use Binaryk\LaravelRestify\Filter; +use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Services\Search\RepositorySearchService; @@ -202,7 +203,7 @@ public static function newModel(): Model public static function query(RestifyRequest $request) { - if (! $request->isViaRepository()) { + if (!$request->isViaRepository()) { return static::newModel()->query(); } @@ -255,12 +256,16 @@ public function collectFields(RestifyRequest $request) $method = 'fieldsForStore'; } + if ($request->isStoreBulkRequest() && method_exists($this, 'fieldsForStoreBulk')) { + $method = 'fieldsForStoreBulk'; + } + $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)); + ->filter(fn($attribute) => $fields->contains('attribute', $attribute) === false) + ->map(fn($attribute) => Field::new($attribute)); $fields = $fields->merge($fillable); } @@ -271,14 +276,14 @@ public function collectFields(RestifyRequest $request) private function indexFields(RestifyRequest $request): Collection { return $this->collectFields($request) - ->filter(fn (Field $field) => ! $field->isHiddenOnIndex($request, $this)) + ->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)) + ->filter(fn(Field $field) => !$field->isHiddenOnShow($request, $this)) ->values(); } @@ -296,6 +301,13 @@ private function storeFields(RestifyRequest $request) ->authorizedStore($request); } + private function storeBulkFields(RestifyRequest $request) + { + return $this->collectFields($request) + ->forStoreBulk($request, $this) + ->authorizedStore($request); + } + /** * @param $resource * @return Repository @@ -378,10 +390,10 @@ public static function routes(Router $router, $attributes, $wrap = false) public function resolveShowAttributes(RestifyRequest $request) { $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) + ->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) { @@ -399,7 +411,7 @@ public function resolveShowAttributes(RestifyRequest $request) return false; } - if (! $field->authorize($request)) { + if (!$field->authorize($request)) { return false; } @@ -420,10 +432,10 @@ 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) + ->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) { @@ -441,7 +453,7 @@ public function resolveIndexAttributes($request) return false; } - if (! $field->authorize($request)) { + if (!$field->authorize($request)) { return false; } @@ -487,7 +499,7 @@ public function resolveRelationships($request): array /** * @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) => [ + $withs[$relation] = $paginator->getCollection()->map(fn(Model $item) => [ 'attributes' => $item->toArray(), ]); } @@ -543,10 +555,10 @@ public function index(RestifyRequest $request) return $this->response([ 'meta' => $this->resolveIndexMainMeta( - $request, $items->map(fn (self $repository) => $repository->resource), RepositoryCollection::meta($paginator->toArray()) + $request, $items->map(fn(self $repository) => $repository->resource), RepositoryCollection::meta($paginator->toArray()) ) ?? RepositoryCollection::meta($paginator->toArray()), 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), - 'data' => $items->map(fn (self $repository) => $repository->serializeForIndex($request)), + 'data' => $items->map(fn(self $repository) => $repository->serializeForIndex($request)), ]); } @@ -578,7 +590,7 @@ public function store(RestifyRequest $request) $this->resource->save(); } - $this->storeFields($request)->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); + $this->storeFields($request)->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); }); static::stored($this->resource, $request); @@ -589,6 +601,31 @@ public function store(RestifyRequest $request) ->header('Location', static::uriTo($this->resource)); } + public function storeBulk(RepositoryStoreBulkRequest $request) + { + $entities = DB::transaction(function () use ($request) { + return $request->collectInput() + ->map(function (array $input, $row) use ($request) { + $this->resource = static::newModel(); + + static::fillBulkFields( + $request, $this->resource, $this->storeBulkFields($request), $row + ); + + $this->resource->save(); + + $this->storeBulkFields($request)->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); + + return $this->resource; + }); + }); + + static::storedBulk($entities, $request); + + return $this->response() + ->created(); + } + public function update(RestifyRequest $request, $repositoryId) { $this->resource = DB::transaction(function () use ($request) { @@ -609,8 +646,8 @@ public function update(RestifyRequest $request, $repositoryId) public function attach(RestifyRequest $request, $repositoryId, Collection $pivots) { DB::transaction(function () use ($request, $pivots) { - return $pivots->map(fn ($pivot) => $pivot->forceFill($request->except($request->relatedRepository))) - ->map(fn ($pivot) => $pivot->save()); + return $pivots->map(fn($pivot) => $pivot->forceFill($request->except($request->relatedRepository))) + ->map(fn($pivot) => $pivot->save()); }); return $this->response() @@ -621,7 +658,7 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot public function detach(RestifyRequest $request, $repositoryId, Collection $pivots) { $deleted = DB::transaction(function () use ($request, $pivots) { - return $pivots->map(fn ($pivot) => $pivot->delete()); + return $pivots->map(fn($pivot) => $pivot->delete()); }); return $this->response() @@ -662,6 +699,17 @@ public function allowToStore(RestifyRequest $request, $payload = null): self return $this; } + public function allowToBulkStore(RestifyRequest $request, $payload = null): self + { + static::authorizeToStoreBulk($request); + + $validator = static::validatorForStoringBulk($request, $payload); + + $validator->validate(); + + return $this; + } + public function allowToDestroy(RestifyRequest $request) { $this->authorizeToDelete($request); @@ -681,6 +729,11 @@ public static function stored($repository, $request) // } + public static function storedBulk(Collection $repositories, $request) + { + // + } + public static function updated($model, $request) { // @@ -699,7 +752,7 @@ public function response($content = '', $status = 200, array $headers = []): Res public function serializeForShow(RestifyRequest $request): array { return $this->filter([ - 'id' => $this->when($this->resource->id, fn () => $this->getShowId($request)), + '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), @@ -712,7 +765,7 @@ 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), + '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), ]); @@ -753,14 +806,22 @@ protected static function fillFields(RestifyRequest $request, Model $model, Coll }); } + protected static function fillBulkFields(RestifyRequest $request, Model $model, Collection $fields, int $bulkRow = null) + { + return $fields->map(function (Field $field) use ($request, $model, $bulkRow) { + return $field->fillAttribute($request, $model, $bulkRow); + }); + } + + public static function uriTo(Model $model) { - return Restify::path().'/'.static::uriKey().'/'.$model->getKey(); + return Restify::path() . '/' . static::uriKey() . '/' . $model->getKey(); } public function availableFilters(RestifyRequest $request) { - return collect($this->filter($this->filters($request)))->each(fn (Filter $filter) => $filter->authorizedToSee($request)) + return collect($this->filter($this->filters($request)))->each(fn(Filter $filter) => $filter->authorizedToSee($request)) ->values(); } diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 558507992..209940b88 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -13,7 +13,7 @@ trait ValidatingTrait { /** - * @param RestifyRequest $request + * @param RestifyRequest $request * @return Collection */ abstract public function collectFields(RestifyRequest $request); @@ -30,12 +30,13 @@ abstract public static function newModel(); */ public static function validatorForStoring(RestifyRequest $request, array $plainPayload = null) { + /** * @var Repository $on */ $on = static::resolveWith(static::newModel()); $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages[$k->attribute.'.'.$ruleFor] = $message; + $messages[$k->attribute . '.' . $ruleFor] = $message; } return $messages; @@ -47,10 +48,30 @@ public static function validatorForStoring(RestifyRequest $request, array $plain }); } + public static function validatorForStoringBulk(RestifyRequest $request, array $plainPayload = null) + { + /** * @var Repository $on */ + $on = static::resolveWith(static::newModel()); + + $messages = $on->collectFields($request)->flatMap(function ($k) { + $messages = []; + foreach ($k->messages as $ruleFor => $message) { + $messages['*' . $k->attribute . '.' . $ruleFor] = $message; + } + + return $messages; + })->toArray(); + + return Validator::make($plainPayload ?? $request->all(), $on->getStoringBulkRules($request), $messages)->after(function ($validator) use ($request) { + static::afterValidation($request, $validator); + static::afterStoringBulkValidation($request, $validator); + }); + } + /** * Validate a resource update request. - * @param RestifyRequest $request - * @param null $resource + * @param RestifyRequest $request + * @param null $resource */ public static function validateForUpdate(RestifyRequest $request, $resource = null) { @@ -65,12 +86,13 @@ public static function validateForUpdate(RestifyRequest $request, $resource = nu */ public static function validatorForUpdate(RestifyRequest $request, $resource = null, array $plainPayload = null) { + /** * @var Repository $on */ $on = $resource ?? static::resolveWith(static::newModel()); $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages[$k->attribute.'.'.$ruleFor] = $message; + $messages[$k->attribute . '.' . $ruleFor] = $message; } return $messages; @@ -85,8 +107,8 @@ public static function validatorForUpdate(RestifyRequest $request, $resource = n /** * Handle any post-validation processing. * - * @param RestifyRequest $request - * @param \Illuminate\Validation\Validator $validator + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator * @return void */ protected static function afterValidation(RestifyRequest $request, $validator) @@ -97,19 +119,23 @@ protected static function afterValidation(RestifyRequest $request, $validator) /** * Handle any post-storing validation processing. * - * @param RestifyRequest $request - * @param \Illuminate\Validation\Validator $validator + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator * @return void */ protected static function afterStoringValidation(RestifyRequest $request, $validator) { } + protected static function afterStoringBulkValidation(RestifyRequest $request, $validator) + { + } + /** * Handle any post-storing validation processing. * - * @param RestifyRequest $request - * @param \Illuminate\Validation\Validator $validator + * @param RestifyRequest $request + * @param \Illuminate\Validation\Validator $validator * @return void */ protected static function afterUpdatingValidation(RestifyRequest $request, $validator) @@ -117,7 +143,7 @@ protected static function afterUpdatingValidation(RestifyRequest $request, $vali } /** - * @param RestifyRequest $request + * @param RestifyRequest $request * @return array */ public function getStoringRules(RestifyRequest $request) @@ -129,8 +155,17 @@ public function getStoringRules(RestifyRequest $request) })->toArray(); } + public function getStoringBulkRules(RestifyRequest $request) + { + return $this->collectFields($request)->mapWithKeys(function (Field $k) { + return [ + "*.{$k->attribute}" => $k->getStoringBulkRules(), + ]; + })->toArray(); + } + /** - * @param RestifyRequest $request + * @param RestifyRequest $request * @return array */ public function getUpdatingRules(RestifyRequest $request) diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 510fb5783..e76d2119c 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -98,6 +98,13 @@ public static function authorizeToStore(Request $request) } } + public static function authorizeToStoreBulk(Request $request) + { + if (! static::authorizedToStoreBulk($request)) { + throw new AuthorizationException('Unauthorized to store bulk.'); + } + } + /** * Determine if the current user can store new repositories. * @@ -113,6 +120,15 @@ public static function authorizedToStore(Request $request) return true; } + public static function authorizedToStoreBulk(Request $request) + { + if (static::authorizable()) { + return Gate::check('storeBulk', static::$model); + } + + return true; + } + /** * Determine if the current user can update the given resource or throw an exception. * diff --git a/tests/Controllers/RepositoryStoreBulkControllerTest.php b/tests/Controllers/RepositoryStoreBulkControllerTest.php new file mode 100644 index 000000000..56a439ccc --- /dev/null +++ b/tests/Controllers/RepositoryStoreBulkControllerTest.php @@ -0,0 +1,71 @@ +authenticate(); + } + + public function test_basic_validation_works() + { + $this->postJson('/restify-api/posts/bulk', [ + [ + 'title' => null, + ] + ]) + ->assertStatus(400) + ->assertJson([ + 'errors' => [ + [ + '0.title' => [ + 'This field is required', + ], + ], + ], + ]); + } + + public function test_unauthorized_store_bulk() + { + $_SERVER['restify.post.storeBulk'] = false; + + Gate::policy(Post::class, PostPolicy::class); + + $this->postJson('/restify-api/posts/bulk', [ + [ + 'title' => 'Title', + 'description' => 'Title', + ] + ])->assertStatus(403) + ->assertJson(['errors' => ['Unauthorized to store bulk.']]); + } + + + public function test_user_can_bulk_create_posts() + { + $user = $this->mockUsers()->first(); + + $this->postJson('/restify-api/posts/bulk', [ + [ + 'user_id' => $user->id, + 'title' => 'First post.', + ], + [ + 'user_id' => $user->id, + 'title' => 'Second post.', + ] + ]) + ->assertStatus(201); + + $this->assertDatabaseCount('posts', 2); + } +} diff --git a/tests/Fixtures/Post/PostPolicy.php b/tests/Fixtures/Post/PostPolicy.php index ed4fda98d..8e95bf325 100644 --- a/tests/Fixtures/Post/PostPolicy.php +++ b/tests/Fixtures/Post/PostPolicy.php @@ -25,6 +25,14 @@ public function store($user) return $_SERVER['restify.post.creatable'] ?? true; } + /** + * Determine if posts can be stored bulk. + */ + public function storeBulk($user) + { + return $_SERVER['restify.post.storeBulk'] ?? true; + } + public function update($user, $post) { return $_SERVER['restify.post.updateable'] ?? true; diff --git a/tests/Fixtures/Post/PostRepository.php b/tests/Fixtures/Post/PostRepository.php index 729c52d54..68438a964 100644 --- a/tests/Fixtures/Post/PostRepository.php +++ b/tests/Fixtures/Post/PostRepository.php @@ -51,6 +51,17 @@ public function fieldsForStore(RestifyRequest $request) ]; } + public function fieldsForStoreBulk(RestifyRequest $request) + { + return [ + Field::new('title')->storeBulkRules('required')->messages([ + 'required' => 'This field is required', + ]), + + Field::new('user_id'), + ]; + } + public function filters(RestifyRequest $request) { return [ From 8e0c12f3f0bf2402e78dfb342a2be6fe09ef8093 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 3 Jul 2020 16:33:41 +0300 Subject: [PATCH 2/6] Apply fixes from StyleCI (#211) --- src/Fields/Field.php | 2 +- .../RepositoryStoreBulkController.php | 1 - src/Repositories/Repository.php | 55 +++++++++---------- src/Repositories/ValidatingTrait.php | 6 +- .../RepositoryStoreBulkControllerTest.php | 11 ++-- 5 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/Fields/Field.php b/src/Fields/Field.php index facbe820c..e7572142f 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -268,7 +268,7 @@ protected function fillAttributeFromRequest(RestifyRequest $request, $model, $at return; } - $bulkableAttribute = $bulkRow . '.' . $attribute; + $bulkableAttribute = $bulkRow.'.'.$attribute; if ($request->exists($bulkableAttribute) || $request->get($bulkableAttribute)) { $model->{$attribute} = $request[$bulkableAttribute] ?? $request->get($bulkableAttribute); diff --git a/src/Http/Controllers/RepositoryStoreBulkController.php b/src/Http/Controllers/RepositoryStoreBulkController.php index 526cbb606..4752cbb82 100644 --- a/src/Http/Controllers/RepositoryStoreBulkController.php +++ b/src/Http/Controllers/RepositoryStoreBulkController.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Http\Controllers; use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest; -use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; class RepositoryStoreBulkController extends RepositoryController { diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 09bd8295a..2d939f057 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -203,7 +203,7 @@ public static function newModel(): Model public static function query(RestifyRequest $request) { - if (!$request->isViaRepository()) { + if (! $request->isViaRepository()) { return static::newModel()->query(); } @@ -264,8 +264,8 @@ public function collectFields(RestifyRequest $request) if ($this instanceof Mergeable) { $fillable = collect($this->resource->getFillable()) - ->filter(fn($attribute) => $fields->contains('attribute', $attribute) === false) - ->map(fn($attribute) => Field::new($attribute)); + ->filter(fn ($attribute) => $fields->contains('attribute', $attribute) === false) + ->map(fn ($attribute) => Field::new($attribute)); $fields = $fields->merge($fillable); } @@ -276,14 +276,14 @@ public function collectFields(RestifyRequest $request) private function indexFields(RestifyRequest $request): Collection { return $this->collectFields($request) - ->filter(fn(Field $field) => !$field->isHiddenOnIndex($request, $this)) + ->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)) + ->filter(fn (Field $field) => ! $field->isHiddenOnShow($request, $this)) ->values(); } @@ -390,10 +390,10 @@ public static function routes(Router $router, $attributes, $wrap = false) public function resolveShowAttributes(RestifyRequest $request) { $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) + ->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) { @@ -411,7 +411,7 @@ public function resolveShowAttributes(RestifyRequest $request) return false; } - if (!$field->authorize($request)) { + if (! $field->authorize($request)) { return false; } @@ -432,10 +432,10 @@ 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) + ->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) { @@ -453,7 +453,7 @@ public function resolveIndexAttributes($request) return false; } - if (!$field->authorize($request)) { + if (! $field->authorize($request)) { return false; } @@ -499,7 +499,7 @@ public function resolveRelationships($request): array /** * @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) => [ + $withs[$relation] = $paginator->getCollection()->map(fn (Model $item) => [ 'attributes' => $item->toArray(), ]); } @@ -555,10 +555,10 @@ public function index(RestifyRequest $request) return $this->response([ 'meta' => $this->resolveIndexMainMeta( - $request, $items->map(fn(self $repository) => $repository->resource), RepositoryCollection::meta($paginator->toArray()) + $request, $items->map(fn (self $repository) => $repository->resource), RepositoryCollection::meta($paginator->toArray()) ) ?? RepositoryCollection::meta($paginator->toArray()), 'links' => RepositoryCollection::paginationLinks($paginator->toArray()), - 'data' => $items->map(fn(self $repository) => $repository->serializeForIndex($request)), + 'data' => $items->map(fn (self $repository) => $repository->serializeForIndex($request)), ]); } @@ -590,7 +590,7 @@ public function store(RestifyRequest $request) $this->resource->save(); } - $this->storeFields($request)->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); + $this->storeFields($request)->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); }); static::stored($this->resource, $request); @@ -614,7 +614,7 @@ public function storeBulk(RepositoryStoreBulkRequest $request) $this->resource->save(); - $this->storeBulkFields($request)->each(fn(Field $field) => $field->invokeAfter($request, $this->resource)); + $this->storeBulkFields($request)->each(fn (Field $field) => $field->invokeAfter($request, $this->resource)); return $this->resource; }); @@ -646,8 +646,8 @@ public function update(RestifyRequest $request, $repositoryId) public function attach(RestifyRequest $request, $repositoryId, Collection $pivots) { DB::transaction(function () use ($request, $pivots) { - return $pivots->map(fn($pivot) => $pivot->forceFill($request->except($request->relatedRepository))) - ->map(fn($pivot) => $pivot->save()); + return $pivots->map(fn ($pivot) => $pivot->forceFill($request->except($request->relatedRepository))) + ->map(fn ($pivot) => $pivot->save()); }); return $this->response() @@ -658,7 +658,7 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot public function detach(RestifyRequest $request, $repositoryId, Collection $pivots) { $deleted = DB::transaction(function () use ($request, $pivots) { - return $pivots->map(fn($pivot) => $pivot->delete()); + return $pivots->map(fn ($pivot) => $pivot->delete()); }); return $this->response() @@ -752,7 +752,7 @@ public function response($content = '', $status = 200, array $headers = []): Res public function serializeForShow(RestifyRequest $request): array { return $this->filter([ - 'id' => $this->when($this->resource->id, fn() => $this->getShowId($request)), + '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), @@ -765,7 +765,7 @@ 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), + '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), ]); @@ -813,15 +813,14 @@ protected static function fillBulkFields(RestifyRequest $request, Model $model, }); } - public static function uriTo(Model $model) { - return Restify::path() . '/' . static::uriKey() . '/' . $model->getKey(); + return Restify::path().'/'.static::uriKey().'/'.$model->getKey(); } public function availableFilters(RestifyRequest $request) { - return collect($this->filter($this->filters($request)))->each(fn(Filter $filter) => $filter->authorizedToSee($request)) + return collect($this->filter($this->filters($request)))->each(fn (Filter $filter) => $filter->authorizedToSee($request)) ->values(); } diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 209940b88..9ac670c71 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -36,7 +36,7 @@ public static function validatorForStoring(RestifyRequest $request, array $plain $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages[$k->attribute . '.' . $ruleFor] = $message; + $messages[$k->attribute.'.'.$ruleFor] = $message; } return $messages; @@ -56,7 +56,7 @@ public static function validatorForStoringBulk(RestifyRequest $request, array $p $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages['*' . $k->attribute . '.' . $ruleFor] = $message; + $messages['*'.$k->attribute.'.'.$ruleFor] = $message; } return $messages; @@ -92,7 +92,7 @@ public static function validatorForUpdate(RestifyRequest $request, $resource = n $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages[$k->attribute . '.' . $ruleFor] = $message; + $messages[$k->attribute.'.'.$ruleFor] = $message; } return $messages; diff --git a/tests/Controllers/RepositoryStoreBulkControllerTest.php b/tests/Controllers/RepositoryStoreBulkControllerTest.php index 56a439ccc..1290b30b1 100644 --- a/tests/Controllers/RepositoryStoreBulkControllerTest.php +++ b/tests/Controllers/RepositoryStoreBulkControllerTest.php @@ -18,9 +18,9 @@ protected function setUp(): void public function test_basic_validation_works() { $this->postJson('/restify-api/posts/bulk', [ - [ - 'title' => null, - ] + [ + 'title' => null, + ], ]) ->assertStatus(400) ->assertJson([ @@ -44,12 +44,11 @@ public function test_unauthorized_store_bulk() [ 'title' => 'Title', 'description' => 'Title', - ] + ], ])->assertStatus(403) ->assertJson(['errors' => ['Unauthorized to store bulk.']]); } - public function test_user_can_bulk_create_posts() { $user = $this->mockUsers()->first(); @@ -62,7 +61,7 @@ public function test_user_can_bulk_create_posts() [ 'user_id' => $user->id, 'title' => 'Second post.', - ] + ], ]) ->assertStatus(201); From 84dfaa612c420ea755bd9f2a7eb35a6e7c68da1c Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 3 Jul 2020 16:52:42 +0300 Subject: [PATCH 3/6] docs --- .../repository-pattern/repository-pattern.md | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/docs/3.0/repository-pattern/repository-pattern.md b/docs/docs/3.0/repository-pattern/repository-pattern.md index 7b5cd7360..2f3001ad7 100644 --- a/docs/docs/3.0/repository-pattern/repository-pattern.md +++ b/docs/docs/3.0/repository-pattern/repository-pattern.md @@ -43,6 +43,7 @@ You have available the follow endpoints: | GET | `/restify-api/posts` | index | | GET | `/restify-api/posts/{post}` | show | | POST | `/restify-api/posts` | store | +| POST | `/restify-api/posts/bulk` | store multiple | | PATCH | `/restify-api/posts/{post}` | update | | PUT | `/restify-api/posts/{post}` | update | | POST | `/restify-api/posts/{post}` | update | @@ -133,7 +134,7 @@ public static function collectMiddlewares(RestifyRequest $request): ?Collection ## Dependency injection -The Laravel [service container](https://laravel.com/docs/6.x/container) is used to resolve all Laravel Restify repositories. +The Laravel [service container](https://laravel.com/docs/7.x/container) is used to resolve all Laravel Restify repositories. As a result, you are able to type-hint any dependencies your `Repository` may need in its constructor. The declared dependencies will automatically be resolved and injected into the repository instance: @@ -208,6 +209,15 @@ entire logic of a specific action. Let's say your `save` method has to do someth } ``` +### store bulk + +```php + public function storeBulk(Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest $request) + { + // Silence is golden + } +``` + ### update ```php @@ -592,4 +602,43 @@ you may want to force eager load a relationship in terms of using it in fields, public static $with = ['posts']; ``` +## Store bulk flow + +However, the `store` method is a common one, the `store bulk` requires a bit of attention. + +### Bulk field validations + +Similar with `store` and `update` methods, `bulk` rules has their own field rule definition: + +```php +->storeBulkRules('required', function () {}, Rule::in('posts:id')) +``` + +The validation rules will be merged with the rules provided into the `rules()` method. The validation will be performed +by using native Laravel validator, so you will have exactly the same experience. The validation `messages` could still be used as usual. +### Bulk Payload + +The payload for a bulk store should contain an array of objects: + +```json +[ + { + "title": "First post" + }, + { + "title": "Second post" + } +] +``` + +### Bulk after store + +After storing an entity, the repository will call the static `bulkStored` method from the repository, so you can override: + +```php +public static function storedBulk(Collection $repositories, $request) +{ + // +} +``` From 9e0bfe33b25a6a7e6786dce25bde9826a5aa8f53 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 3 Jul 2020 17:28:02 +0300 Subject: [PATCH 4/6] Bulk update --- .../repository-pattern/repository-pattern.md | 40 ++++++ .../RepositoryUpdateBulkController.php | 16 +++ .../Requests/RepositoryUpdateBulkRequest.php | 15 +++ .../RepositoryUpdateBulkControllerTest.php | 116 ++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 src/Http/Controllers/RepositoryUpdateBulkController.php create mode 100644 src/Http/Requests/RepositoryUpdateBulkRequest.php create mode 100644 tests/Controllers/RepositoryUpdateBulkControllerTest.php diff --git a/docs/docs/3.0/repository-pattern/repository-pattern.md b/docs/docs/3.0/repository-pattern/repository-pattern.md index 2f3001ad7..c53c127ad 100644 --- a/docs/docs/3.0/repository-pattern/repository-pattern.md +++ b/docs/docs/3.0/repository-pattern/repository-pattern.md @@ -44,6 +44,7 @@ You have available the follow endpoints: | GET | `/restify-api/posts/{post}` | show | | POST | `/restify-api/posts` | store | | POST | `/restify-api/posts/bulk` | store multiple | +| POST | `/restify-api/posts/bulk/update` | store multiple | | PATCH | `/restify-api/posts/{post}` | update | | PUT | `/restify-api/posts/{post}` | update | | POST | `/restify-api/posts/{post}` | update | @@ -227,6 +228,17 @@ entire logic of a specific action. Let's say your `save` method has to do someth } ``` +### update bulk + +// $row is the payload row to be updated + +```php + public function updateBulk(RestifyRequest $request, $repositoryId, int $row) + { + // Silence is golden + } +``` + ### destroy ```php @@ -642,3 +654,31 @@ public static function storedBulk(Collection $repositories, $request) // } ``` + +## Update bulk flow + +As the store bulk, the update bulk uses DB transaction to perform the action. So you can make sure that even all entries, even no one where updated. + +### Bulk update field validations + +```php +->updateBulkRules('required', function () {}, Rule::in('posts:id')) +``` + +### Bulk Payload + +The payload for a bulk update should contain an array of objects. Each object SHOULD contain an `id` key, based on this, the Laravel Restify will find the entity: + +```json +[ + { + "id": 1, + "title": "First post" + }, + { + "id": 2, + "title": "Second post" + } +] +``` + diff --git a/src/Http/Controllers/RepositoryUpdateBulkController.php b/src/Http/Controllers/RepositoryUpdateBulkController.php new file mode 100644 index 000000000..526cbb606 --- /dev/null +++ b/src/Http/Controllers/RepositoryUpdateBulkController.php @@ -0,0 +1,16 @@ +repository() + ->allowToBulkStore($request) + ->storeBulk($request); + } +} diff --git a/src/Http/Requests/RepositoryUpdateBulkRequest.php b/src/Http/Requests/RepositoryUpdateBulkRequest.php new file mode 100644 index 000000000..87a23ea70 --- /dev/null +++ b/src/Http/Requests/RepositoryUpdateBulkRequest.php @@ -0,0 +1,15 @@ +all(), + ); + } +} diff --git a/tests/Controllers/RepositoryUpdateBulkControllerTest.php b/tests/Controllers/RepositoryUpdateBulkControllerTest.php new file mode 100644 index 000000000..a65d5499a --- /dev/null +++ b/tests/Controllers/RepositoryUpdateBulkControllerTest.php @@ -0,0 +1,116 @@ + + */ +class RepositoryUpdateControllerTest extends IntegrationTest +{ + protected function setUp(): void + { + parent::setUp(); + + $this->authenticate(); + } + + public function test_basic_update_works() + { + $post = factory(Post::class)->create(['user_id' => 1]); + + $this->withoutExceptionHandling()->patch('/restify-api/posts/'.$post->id, [ + 'title' => 'Updated title', + ]) + ->assertStatus(200); + + $updatedPost = Post::find($post->id); + + $this->assertEquals($updatedPost->title, 'Updated title'); + } + + public function test_put_works() + { + $post = factory(Post::class)->create(['user_id' => 1]); + + $this->withoutExceptionHandling()->put('/restify-api/posts/'.$post->id, [ + 'title' => 'Updated title', + ]) + ->assertStatus(200); + + $updatedPost = Post::find($post->id); + + $this->assertEquals($updatedPost->title, 'Updated title'); + } + + public function test_unathorized_to_update() + { + $this->app->bind(ExceptionHandler::class, RestifyHandler::class); + + Gate::policy(Post::class, PostPolicy::class); + + $post = factory(Post::class)->create(['user_id' => 1]); + + $_SERVER['restify.post.updateable'] = false; + + $this->patch('/restify-api/posts/'.$post->id, [ + 'title' => 'Updated title', + ])->assertStatus(403) + ->assertJson([ + 'errors' => ['This action is unauthorized.'], + ]); + } + + public function test_do_not_update_fields_without_permission() + { + $post = factory(Post::class)->create(['user_id' => 1, 'title' => 'Title']); + + $_SERVER['posts.authorizable.title'] = false; + + $response = $this->putJson('/restify-api/post-with-unathorized-fields/'.$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() + { + $post = factory(Post::class)->create(['user_id' => 1, 'title' => 'Title', 'image' => 'red.png']); + + $response = $this->putJson('/restify-api/posts-mergeable/'.$post->id, [ + 'title' => 'Updated title', + 'image' => 'image.png', // via mergeable + ]) + ->assertStatus(200); + + $this->assertEquals('Updated title', $response->json('data.attributes.title')); + $this->assertEquals('image.png', $response->json('data.attributes.image')); // via extra + } + + 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', + ]) + ->assertStatus(200); + + $this->assertNull($r->json('data.attributes.image')); + } +} From d4a3292a5133aae088e787d3b6770cb89a121a17 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 3 Jul 2020 17:29:31 +0300 Subject: [PATCH 5/6] Update bulk --- routes/api.php | 2 + src/Fields/Field.php | 14 +- src/Fields/FieldCollection.php | 14 ++ src/Fields/OrganicField.php | 21 +++ .../RepositoryUpdateBulkController.php | 32 ++++- .../Requests/RepositoryUpdateBulkRequest.php | 2 +- src/Http/Requests/RestifyRequest.php | 6 + src/Repositories/Repository.php | 34 +++++ src/Repositories/ValidatingTrait.php | 57 ++++---- src/Traits/AuthorizableModels.php | 5 + .../RepositoryUpdateBulkControllerTest.php | 131 ++++++------------ tests/Fixtures/Post/PostRepository.php | 11 ++ 12 files changed, 208 insertions(+), 121 deletions(-) diff --git a/routes/api.php b/routes/api.php index 8ff3772a6..e68b7979f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController; +use Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController; use Illuminate\Support\Facades\Route; @@ -29,6 +30,7 @@ Route::get('/{repository}', '\\'.RepositoryIndexController::class); Route::post('/{repository}', '\\'.RepositoryStoreController::class); Route::post('/{repository}/bulk', '\\'.RepositoryStoreBulkController::class); +Route::post('/{repository}/bulk/update', '\\'.RepositoryUpdateBulkController::class); Route::get('/{repository}/{repositoryId}', '\\'.RepositoryShowController::class); Route::patch('/{repository}/{repositoryId}', '\\'.RepositoryUpdateController::class); Route::put('/{repository}/{repositoryId}', '\\'.RepositoryUpdateController::class); diff --git a/src/Fields/Field.php b/src/Fields/Field.php index facbe820c..720e38ca3 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -324,6 +324,13 @@ public function storeBulkRules($rules) return $this; } + public function updateBulkRules($rules) + { + $this->updateBulkRules = ($rules instanceof Rule || is_string($rules)) ? func_get_args() : $rules; + + return $this; + } + /** * Alias for storingRules - to maintain it consistent. * @@ -374,7 +381,7 @@ public function getStoringRules(): array public function getStoringBulkRules(): array { - return $this->storingBulkRules; + return array_merge($this->rules, $this->storingBulkRules); } public function getUpdatingRules(): array @@ -382,6 +389,11 @@ public function getUpdatingRules(): array return array_merge($this->rules, $this->updatingRules); } + public function getUpdatingBulkRules(): array + { + return array_merge($this->rules, $this->updateBulkRules); + } + /** * Resolve the field's value for display. * diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index f642323a2..1a38d4590 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -22,6 +22,13 @@ public function authorizedUpdate(Request $request): self })->values(); } + public function authorizedUpdateBulk(Request $request): self + { + return $this->filter(function (OrganicField $field) use ($request) { + return $field->authorizedToUpdateBulk($request); + })->values(); + } + public function authorizedStore(Request $request): self { return $this->filter(function (OrganicField $field) use ($request) { @@ -70,4 +77,11 @@ public function forUpdate(RestifyRequest $request, $repository): self return $field->isShownOnUpdate($request, $repository); })->values(); } + + public function forUpdateBulk(RestifyRequest $request, $repository): self + { + return $this->filter(function (Field $field) use ($repository, $request) { + return $field->isShownOnUpdateBulk($request, $repository); + })->values(); + } } diff --git a/src/Fields/OrganicField.php b/src/Fields/OrganicField.php index cc2ab1aae..3ad937502 100644 --- a/src/Fields/OrganicField.php +++ b/src/Fields/OrganicField.php @@ -12,6 +12,8 @@ abstract class OrganicField extends BaseField public $canUpdateCallback; + public $canUpdateBulkCallback; + public $canStoreCallback; public $readonlyCallback; @@ -24,6 +26,8 @@ abstract class OrganicField extends BaseField public array $storingBulkRules = []; + public array $updateBulkRules = []; + public array $updatingRules = []; public array $messages = []; @@ -117,6 +121,11 @@ public function authorizedToUpdate(Request $request) return $this->canUpdateCallback ? call_user_func($this->canUpdateCallback, $request) : true; } + public function authorizedToUpdateBulk(Request $request) + { + return $this->canUpdateBulkCallback ? call_user_func($this->canUpdateBulkCallback, $request) : true; + } + public function authorizedToStore(Request $request) { return $this->canStoreCallback ? call_user_func($this->canStoreCallback, $request) : true; @@ -136,6 +145,13 @@ public function canUpdate(Closure $callback) return $this; } + public function canUpdateBulk(Closure $callback) + { + $this->canUpdateBulkCallback = $callback; + + return $this; + } + public function canStore(Closure $callback) { $this->canStoreCallback = $callback; @@ -166,6 +182,11 @@ public function isShownOnUpdate(RestifyRequest $request, $repository): bool return ! $this->isReadonly($request); } + public function isShownOnUpdateBulk(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/RepositoryUpdateBulkController.php b/src/Http/Controllers/RepositoryUpdateBulkController.php index 526cbb606..82fdcdcc5 100644 --- a/src/Http/Controllers/RepositoryUpdateBulkController.php +++ b/src/Http/Controllers/RepositoryUpdateBulkController.php @@ -2,15 +2,33 @@ namespace Binaryk\LaravelRestify\Http\Controllers; -use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest; -use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; +use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateBulkRequest; +use Binaryk\LaravelRestify\Repositories\Repository; +use Illuminate\Support\Facades\DB; -class RepositoryStoreBulkController extends RepositoryController +class RepositoryUpdateBulkController extends RepositoryController { - public function __invoke(RepositoryStoreBulkRequest $request) + public function __invoke(RepositoryUpdateBulkRequest $request) { - return $request->repository() - ->allowToBulkStore($request) - ->storeBulk($request); + $collection = DB::transaction(function () use ($request) { + return $request->collectInput() + ->each(function (array $item, int $row) use ($request) { + $model = $request->findModelQuery( + $id = $item['id'] + )->lockForUpdate()->firstOrFail(); + + /** * @var Repository $repository */ + $repository = $request->newRepositoryWith($model); + + return $repository + ->allowToUpdateBulk($request) + ->updateBulk( + $request, $id, $row + ); + }); + }); + + return $this->response() + ->success(); } } diff --git a/src/Http/Requests/RepositoryUpdateBulkRequest.php b/src/Http/Requests/RepositoryUpdateBulkRequest.php index 87a23ea70..d9d6b06cb 100644 --- a/src/Http/Requests/RepositoryUpdateBulkRequest.php +++ b/src/Http/Requests/RepositoryUpdateBulkRequest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Collection; -class RepositoryStoreBulkRequest extends RestifyRequest +class RepositoryUpdateBulkRequest extends RestifyRequest { public function collectInput(): Collection { diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 206f5ed71..9796a0031 100644 --- a/src/Http/Requests/RestifyRequest.php +++ b/src/Http/Requests/RestifyRequest.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Http\Requests; +use Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\App; @@ -63,6 +64,11 @@ public function isStoreBulkRequest() return $this instanceof RepositoryStoreBulkRequest; } + public function isUpdateBulkRequest() + { + return $this instanceof RepositoryUpdateBulkRequest; + } + public function isViaRepository() { return $this->viaRepository && $this->viaRepositoryId; diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 09bd8295a..a5e4a3c0a 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -260,6 +260,10 @@ public function collectFields(RestifyRequest $request) $method = 'fieldsForStoreBulk'; } + if ($request->isUpdateBulkRequest() && method_exists($this, 'fieldsForUpdateBulk')) { + $method = 'fieldsForUpdateBulk'; + } + $fields = FieldCollection::make(array_values($this->filter($this->{$method}($request)))); if ($this instanceof Mergeable) { @@ -294,6 +298,13 @@ private function updateFields(RestifyRequest $request) ->authorizedUpdate($request); } + private function updateBulkFields(RestifyRequest $request) + { + return $this->collectFields($request) + ->forUpdateBulk($request, $this) + ->authorizedUpdateBulk($request); + } + private function storeFields(RestifyRequest $request) { return $this->collectFields($request) @@ -643,6 +654,18 @@ public function update(RestifyRequest $request, $repositoryId) ->success(); } + public function updateBulk(RestifyRequest $request, $repositoryId, int $row) + { + $fields = $this->updateBulkFields($request); + + static::fillBulkFields($request, $this->resource, $fields, $row); + + $this->resource->save(); + + return $this->response() + ->success(); + } + public function attach(RestifyRequest $request, $repositoryId, Collection $pivots) { DB::transaction(function () use ($request, $pivots) { @@ -688,6 +711,17 @@ public function allowToUpdate(RestifyRequest $request, $payload = null): self return $this; } + public function allowToUpdateBulk(RestifyRequest $request, $payload = null): self + { + $this->authorizeToUpdateBulk($request); + + $validator = static::validatorForUpdateBulk($request, $this, $payload); + + $validator->validate(); + + return $this; + } + public function allowToStore(RestifyRequest $request, $payload = null): self { static::authorizeToStore($request); diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index 209940b88..003a1c8e4 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -78,12 +78,6 @@ public static function validateForUpdate(RestifyRequest $request, $resource = nu static::validatorForUpdate($request, $resource)->validate(); } - /** - * @param RestifyRequest $request - * @param null $resource - * @param array $plainPayload - * @return \Illuminate\Contracts\Validation\Validator - */ public static function validatorForUpdate(RestifyRequest $request, $resource = null, array $plainPayload = null) { /** * @var Repository $on */ @@ -104,6 +98,26 @@ public static function validatorForUpdate(RestifyRequest $request, $resource = n }); } + public static function validatorForUpdateBulk(RestifyRequest $request, $resource = null, array $plainPayload = null) + { + /** * @var Repository $on */ + $on = $resource ?? static::resolveWith(static::newModel()); + + $messages = $on->collectFields($request)->flatMap(function ($k) { + $messages = []; + foreach ($k->messages as $ruleFor => $message) { + $messages['*' . $k->attribute . '.' . $ruleFor] = $message; + } + + return $messages; + })->toArray(); + + return Validator::make($plainPayload ?? $request->all(), $on->getUpdatingBulkRules($request), $messages)->after(function ($validator) use ($request) { + static::afterValidation($request, $validator); + static::afterUpdatingBulkValidation($request, $validator); + }); + } + /** * Handle any post-validation processing. * @@ -116,13 +130,6 @@ protected static function afterValidation(RestifyRequest $request, $validator) // } - /** - * Handle any post-storing validation processing. - * - * @param RestifyRequest $request - * @param \Illuminate\Validation\Validator $validator - * @return void - */ protected static function afterStoringValidation(RestifyRequest $request, $validator) { } @@ -131,17 +138,14 @@ protected static function afterStoringBulkValidation(RestifyRequest $request, $v { } - /** - * Handle any post-storing validation processing. - * - * @param RestifyRequest $request - * @param \Illuminate\Validation\Validator $validator - * @return void - */ protected static function afterUpdatingValidation(RestifyRequest $request, $validator) { } + protected static function afterUpdatingBulkValidation(RestifyRequest $request, $validator) + { + } + /** * @param RestifyRequest $request * @return array @@ -164,10 +168,6 @@ public function getStoringBulkRules(RestifyRequest $request) })->toArray(); } - /** - * @param RestifyRequest $request - * @return array - */ public function getUpdatingRules(RestifyRequest $request) { return $this->collectFields($request)->mapWithKeys(function (Field $k) { @@ -176,4 +176,13 @@ public function getUpdatingRules(RestifyRequest $request) ]; })->toArray(); } + + public function getUpdatingBulkRules(RestifyRequest $request) + { + return $this->collectFields($request)->mapWithKeys(function (Field $k) { + return [ + "*.{$k->attribute}" => $k->getUpdatingBulkRules(), + ]; + })->toArray(); + } } diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index e76d2119c..bbd9acf2b 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -142,6 +142,11 @@ public function authorizeToUpdate(Request $request) $this->authorizeTo($request, 'update'); } + public function authorizeToUpdateBulk(Request $request) + { + $this->authorizeTo($request, 'updateBulk'); + } + /** * Determine if the current user can update the given resource. * diff --git a/tests/Controllers/RepositoryUpdateBulkControllerTest.php b/tests/Controllers/RepositoryUpdateBulkControllerTest.php index a65d5499a..695dcbb9a 100644 --- a/tests/Controllers/RepositoryUpdateBulkControllerTest.php +++ b/tests/Controllers/RepositoryUpdateBulkControllerTest.php @@ -2,17 +2,10 @@ namespace Binaryk\LaravelRestify\Tests\Controllers; -use Binaryk\LaravelRestify\Exceptions\RestifyHandler; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; -use Binaryk\LaravelRestify\Tests\Fixtures\Post\PostPolicy; use Binaryk\LaravelRestify\Tests\IntegrationTest; -use Illuminate\Contracts\Debug\ExceptionHandler; -use Illuminate\Support\Facades\Gate; -/** - * @author Eduard Lupacescu - */ -class RepositoryUpdateControllerTest extends IntegrationTest +class RepositoryUpdateBulkControllerTest extends IntegrationTest { protected function setUp(): void { @@ -21,96 +14,58 @@ protected function setUp(): void $this->authenticate(); } - public function test_basic_update_works() - { - $post = factory(Post::class)->create(['user_id' => 1]); - - $this->withoutExceptionHandling()->patch('/restify-api/posts/'.$post->id, [ - 'title' => 'Updated title', - ]) - ->assertStatus(200); - - $updatedPost = Post::find($post->id); - - $this->assertEquals($updatedPost->title, 'Updated title'); - } - - public function test_put_works() + public function test_basic_update_validation_works() { - $post = factory(Post::class)->create(['user_id' => 1]); - - $this->withoutExceptionHandling()->put('/restify-api/posts/'.$post->id, [ - 'title' => 'Updated title', + $post1 = factory(Post::class)->create([ + 'user_id' => 1, + 'title' => 'First title', + ]); + + $this->post('/restify-api/posts/bulk/update', [ + [ + 'id' => $post1->id, + 'title' => null, + ], ]) - ->assertStatus(200); - - $updatedPost = Post::find($post->id); - - $this->assertEquals($updatedPost->title, 'Updated title'); - } - - public function test_unathorized_to_update() - { - $this->app->bind(ExceptionHandler::class, RestifyHandler::class); - - Gate::policy(Post::class, PostPolicy::class); - - $post = factory(Post::class)->create(['user_id' => 1]); - - $_SERVER['restify.post.updateable'] = false; - - $this->patch('/restify-api/posts/'.$post->id, [ - 'title' => 'Updated title', - ])->assertStatus(403) + ->assertStatus(400) ->assertJson([ - 'errors' => ['This action is unauthorized.'], + 'errors' => [ + [ + '0.title' => [ + 'This field is required', + ], + ], + ], ]); } - public function test_do_not_update_fields_without_permission() - { - $post = factory(Post::class)->create(['user_id' => 1, 'title' => 'Title']); - - $_SERVER['posts.authorizable.title'] = false; - - $response = $this->putJson('/restify-api/post-with-unathorized-fields/'.$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() + public function test_basic_update_works() { - $post = factory(Post::class)->create(['user_id' => 1, 'title' => 'Title', 'image' => 'red.png']); - - $response = $this->putJson('/restify-api/posts-mergeable/'.$post->id, [ - 'title' => 'Updated title', - 'image' => 'image.png', // via mergeable + $post1 = factory(Post::class)->create([ + 'user_id' => 1, + 'title' => 'First title', + ]); + $post2 = factory(Post::class)->create([ + 'user_id' => 1, + 'title' => 'Second title', + ]); + + $this->post('/restify-api/posts/bulk/update', [ + [ + 'id' => $post1->id, + 'title' => 'Updated first title', + ], + [ + 'id' => $post2->id, + 'title' => 'Updated second title', + ] ]) ->assertStatus(200); - $this->assertEquals('Updated title', $response->json('data.attributes.title')); - $this->assertEquals('image.png', $response->json('data.attributes.image')); // via extra - } - - 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', - ]) - ->assertStatus(200); + $updatedPost = Post::find($post1->id); + $updatedPost2 = Post::find($post2->id); - $this->assertNull($r->json('data.attributes.image')); + $this->assertEquals($updatedPost->title, 'Updated first title'); + $this->assertEquals($updatedPost2->title, 'Updated second title'); } } diff --git a/tests/Fixtures/Post/PostRepository.php b/tests/Fixtures/Post/PostRepository.php index 68438a964..c2b856314 100644 --- a/tests/Fixtures/Post/PostRepository.php +++ b/tests/Fixtures/Post/PostRepository.php @@ -62,6 +62,17 @@ public function fieldsForStoreBulk(RestifyRequest $request) ]; } + public function fieldsForUpdateBulk(RestifyRequest $request) + { + return [ + Field::new('title')->updateBulkRules('required')->messages([ + 'required' => 'This field is required', + ]), + + Field::new('user_id'), + ]; + } + public function filters(RestifyRequest $request) { return [ From 72a00eb12c742280b57847935b2801cc0dd70776 Mon Sep 17 00:00:00 2001 From: Lupacescu Eduard Date: Fri, 3 Jul 2020 17:29:58 +0300 Subject: [PATCH 6/6] Apply fixes from StyleCI (#212) --- src/Http/Requests/RestifyRequest.php | 1 - src/Repositories/ValidatingTrait.php | 2 +- tests/Controllers/RepositoryUpdateBulkControllerTest.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Http/Requests/RestifyRequest.php b/src/Http/Requests/RestifyRequest.php index 9796a0031..f56a08c4c 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\Http\Controllers\RepositoryUpdateBulkController; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\App; diff --git a/src/Repositories/ValidatingTrait.php b/src/Repositories/ValidatingTrait.php index ad3d8b3f4..9dfb18eeb 100644 --- a/src/Repositories/ValidatingTrait.php +++ b/src/Repositories/ValidatingTrait.php @@ -106,7 +106,7 @@ public static function validatorForUpdateBulk(RestifyRequest $request, $resource $messages = $on->collectFields($request)->flatMap(function ($k) { $messages = []; foreach ($k->messages as $ruleFor => $message) { - $messages['*' . $k->attribute . '.' . $ruleFor] = $message; + $messages['*'.$k->attribute.'.'.$ruleFor] = $message; } return $messages; diff --git a/tests/Controllers/RepositoryUpdateBulkControllerTest.php b/tests/Controllers/RepositoryUpdateBulkControllerTest.php index 695dcbb9a..a4582d0c1 100644 --- a/tests/Controllers/RepositoryUpdateBulkControllerTest.php +++ b/tests/Controllers/RepositoryUpdateBulkControllerTest.php @@ -58,7 +58,7 @@ public function test_basic_update_works() [ 'id' => $post2->id, 'title' => 'Updated second title', - ] + ], ]) ->assertStatus(200);