diff --git a/routes/api.php b/routes/api.php index 1d93040ff..dec06ba36 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use Binaryk\LaravelRestify\Http\Controllers\GlobalSearchController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController; +use Binaryk\LaravelRestify\Http\Controllers\RepositoryFilterController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController; use Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController; @@ -11,6 +12,9 @@ // Global Search... Route::get('/search', '\\'.GlobalSearchController::class); +// Filters +Route::get('/{repository}/filters', '\\'.RepositoryFilterController::class); + // API CRUD Route::get('/{repository}', '\\'.RepositoryIndexController::class); Route::post('/{repository}', '\\'.RepositoryStoreController::class); diff --git a/src/BooleanFilter.php b/src/BooleanFilter.php new file mode 100644 index 000000000..df6e435b1 --- /dev/null +++ b/src/BooleanFilter.php @@ -0,0 +1,19 @@ +options($request))->mapWithKeys(function ($key) use ($filter) { + return [$key => data_get($filter, $key)]; + })->toArray(); + + $this->value = $keyValues; + } +} diff --git a/src/Filter.php b/src/Filter.php new file mode 100644 index 000000000..d3ef8f407 --- /dev/null +++ b/src/Filter.php @@ -0,0 +1,86 @@ +booted(); + } + + protected function booted() + { + // + } + + abstract public function filter(RestifyRequest $request, $query, $value); + + public function canSee(Closure $callback) + { + $this->canSeeCallback = $callback; + + return $this; + } + + public function authorizedToSee(RestifyRequest $request) + { + return $this->canSeeCallback ? call_user_func($this->canSeeCallback, $request) : true; + } + + public function key() + { + return static::class; + } + + protected function getType() + { + return $this->type; + } + + public function options(Request $request) + { + // noop + } + + public function invalidPayloadValue(Request $request, $value) + { + if (is_array($value)) { + return count($value) < 1; + } elseif (is_string($value)) { + return trim($value) === ''; + } + + return is_null($value); + } + + public function resolve(RestifyRequest $request, $filter) + { + $this->value = $filter; + } + + public function jsonSerialize() + { + return [ + 'class' => static::class, + 'type' => $this->getType(), + 'options' => collect($this->options(app(Request::class)))->map(function ($value, $key) { + return is_array($value) ? ($value + ['property' => $key]) : ['label' => $key, 'property' => $value]; + })->values()->all(), + ]; + } +} diff --git a/src/Http/Controllers/RepositoryFilterController.php b/src/Http/Controllers/RepositoryFilterController.php new file mode 100644 index 000000000..cdd416e8f --- /dev/null +++ b/src/Http/Controllers/RepositoryFilterController.php @@ -0,0 +1,15 @@ +repository(); + + return $this->response()->data($repository->availableFilters($request)); + } +} diff --git a/src/Http/Requests/RepositoryFiltersRequest.php b/src/Http/Requests/RepositoryFiltersRequest.php new file mode 100644 index 000000000..9e07896f2 --- /dev/null +++ b/src/Http/Requests/RepositoryFiltersRequest.php @@ -0,0 +1,7 @@ +getKey(); } + + public function availableFilters(RestifyRequest $request) + { + return collect($this->filter($this->filters($request)))->each(fn (Filter $filter) => $filter->authorizedToSee($request)) + ->values(); + } } diff --git a/src/SelectFilter.php b/src/SelectFilter.php new file mode 100644 index 000000000..4a43c7235 --- /dev/null +++ b/src/SelectFilter.php @@ -0,0 +1,8 @@ +prepareMatchFields($request, $this->prepareSearchFields($request, $repository::query(), $this->fixedInput), $this->fixedInput); + $query = $this->applyFilters($request, $repository, $query); + return tap($this->prepareRelations($request, $this->prepareOrders($request, $query), $this->fixedInput), $this->applyIndexQuery($request, $repository)); } @@ -175,4 +178,35 @@ protected function applyIndexQuery(RestifyRequest $request, Repository $reposito { return fn ($query) => $repository::indexQuery($request, $query); } + + protected function applyFilters(RestifyRequest $request, Repository $repository, $query) + { + if (! empty($request->filters)) { + $filters = json_decode(base64_decode($request->filters), true); + + collect($filters) + ->map(function ($filter) use ($request, $repository) { + /** * @var Filter $matchingFilter */ + $matchingFilter = $repository->availableFilters($request)->first(function ($availableFilter) use ($filter) { + return $filter['class'] === $availableFilter->key(); + }); + + if (is_null($matchingFilter)) { + return false; + } + + if ($matchingFilter->invalidPayloadValue($request, $filter['value'])) { + return false; + } + + $matchingFilter->resolve($request, $filter['value']); + + return $matchingFilter; + }) + ->filter() + ->each(fn (Filter $filter) => $filter->filter($request, $query, $filter->value)); + } + + return $query; + } } diff --git a/src/TimestampFilter.php b/src/TimestampFilter.php new file mode 100644 index 000000000..c1f209dab --- /dev/null +++ b/src/TimestampFilter.php @@ -0,0 +1,16 @@ +value = Carbon::parse($value); + } +} diff --git a/tests/Controllers/RepositoryFilterControllerTest.php b/tests/Controllers/RepositoryFilterControllerTest.php new file mode 100644 index 000000000..f5c535799 --- /dev/null +++ b/tests/Controllers/RepositoryFilterControllerTest.php @@ -0,0 +1,91 @@ +withoutExceptionHandling() + ->getJson('restify-api/posts/filters') + ->dump() + ->assertStatus(200); + + $this->assertCount(3, $response->json('data')); + } + + public function test_the_boolean_filter_is_applied() + { + factory(Post::class)->create(['is_active' => false]); + factory(Post::class)->create(['is_active' => true]); + + $filters = base64_encode(json_encode([ + [ + 'class' => ActiveBooleanFilter::class, + 'value' => [ + 'is_active' => false, + ], + ], + ])); + + $response = $this + ->withoutExceptionHandling() + ->getJson('restify-api/posts?filters='.$filters) + ->dump() + ->assertStatus(200); + + $this->assertCount(1, $response->json('data')); + } + + public function test_the_select_filter_is_applied() + { + factory(Post::class)->create(['category' => 'movie']); + factory(Post::class)->create(['category' => 'article']); + + $filters = base64_encode(json_encode([ + [ + 'class' => SelectCategoryFilter::class, + 'value' => 'article', + ], + ])); + + $response = $this + ->withExceptionHandling() + ->getJson('restify-api/posts?filters='.$filters) + ->assertStatus(200); + + $this->assertCount(1, $response->json('data')); + } + + public function test_the_timestamp_filter_is_applied() + { + factory(Post::class)->create(['created_at' => now()->addYear()]); + factory(Post::class)->create(['created_at' => now()->subYear()]); + + $filters = base64_encode(json_encode([ + [ + 'class' => UserRepository::class, + 'value' => now()->addWeek()->timestamp, + ], + [ + 'class' => CreatedAfterDateFilter::class, + 'value' => now()->addWeek()->timestamp, + ], + ])); + + $response = $this + ->withExceptionHandling() + ->getJson('restify-api/posts?filters='.$filters) + ->assertStatus(200); + + $this->assertCount(1, $response->json('data')); + } +} diff --git a/tests/Fixtures/Post/ActiveBooleanFilter.php b/tests/Fixtures/Post/ActiveBooleanFilter.php new file mode 100644 index 000000000..6fdae34e5 --- /dev/null +++ b/tests/Fixtures/Post/ActiveBooleanFilter.php @@ -0,0 +1,22 @@ +where('is_active', $value['is_active']); + } + + public function options(Request $request) + { + return [ + 'Is Active' => 'is_active', + ]; + } +} diff --git a/tests/Fixtures/Post/CreatedAfterDateFilter.php b/tests/Fixtures/Post/CreatedAfterDateFilter.php new file mode 100644 index 000000000..36645d728 --- /dev/null +++ b/tests/Fixtures/Post/CreatedAfterDateFilter.php @@ -0,0 +1,14 @@ +whereDate('created_at', '>', $value); + } +} diff --git a/tests/Fixtures/Post/Post.php b/tests/Fixtures/Post/Post.php index ffb7ce6c1..ba578b9f1 100644 --- a/tests/Fixtures/Post/Post.php +++ b/tests/Fixtures/Post/Post.php @@ -20,6 +20,8 @@ class Post extends Model implements RestifySearchable 'image', 'title', 'description', + 'category', + 'is_active', ]; public function user() diff --git a/tests/Fixtures/Post/PostRepository.php b/tests/Fixtures/Post/PostRepository.php index 6f34543aa..8c607079d 100644 --- a/tests/Fixtures/Post/PostRepository.php +++ b/tests/Fixtures/Post/PostRepository.php @@ -49,4 +49,13 @@ public function fieldsForStore(RestifyRequest $request) ]), ]; } + + public function filters(RestifyRequest $request) + { + return [ + ActiveBooleanFilter::new()->canSee(fn () => true), + SelectCategoryFilter::new(), + CreatedAfterDateFilter::new(), + ]; + } } diff --git a/tests/Fixtures/Post/SelectCategoryFilter.php b/tests/Fixtures/Post/SelectCategoryFilter.php new file mode 100644 index 000000000..625cffd59 --- /dev/null +++ b/tests/Fixtures/Post/SelectCategoryFilter.php @@ -0,0 +1,25 @@ +where('category', $value); + } + + public function options(Request $request) + { + return [ + 'Movie category' => 'movie', + + 'Article Category' => 'article', + ]; + } +} 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 b1fa91dbb..c49b70c58 100644 --- a/tests/Migrations/2019_12_22_000005_create_posts_table.php +++ b/tests/Migrations/2019_12_22_000005_create_posts_table.php @@ -19,6 +19,8 @@ public function up() $table->string('title'); $table->longText('description')->nullable(); $table->string('image')->nullable(); + $table->string('category')->nullable(); + $table->boolean('is_active')->default(true); $table->timestamps(); }); }