diff --git a/app/Http/Requests/Rest/SearchRequest.php b/app/Http/Requests/Rest/SearchRequest.php new file mode 100644 index 00000000..5067dc50 --- /dev/null +++ b/app/Http/Requests/Rest/SearchRequest.php @@ -0,0 +1,18 @@ +route()->controller::newResource(); + + return [ + 'search' => (new Search)->setResource($resource), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5dd94818..bb941d8c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Http\Requests\Rest\SearchRequest as AppSearchRequest; use App\Models\Citation; use App\Models\Collection; use App\Models\GeoLocation; @@ -22,13 +23,17 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; +use Lomkit\Rest\Http\Requests\SearchRequest; class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ - public function register(): void {} + public function register(): void + { + $this->app->extend(SearchRequest::class, fn ($resolved, $app) => $app->make(AppSearchRequest::class)); + } /** * Bootstrap any application services. diff --git a/app/Rules/Search/Search.php b/app/Rules/Search/Search.php new file mode 100644 index 00000000..ef4e88be --- /dev/null +++ b/app/Rules/Search/Search.php @@ -0,0 +1,16 @@ +setResource($this->resource); + + return $rules; + } +} diff --git a/app/Rules/Search/SearchFilter.php b/app/Rules/Search/SearchFilter.php new file mode 100644 index 00000000..7187416a --- /dev/null +++ b/app/Rules/Search/SearchFilter.php @@ -0,0 +1,60 @@ +isScoutMode(); + + $fieldsValidation = $isScoutMode ? + Rule::in($this->resource->getScoutFields($request)) : + (new ResourceFieldOrNested)->setResource($this->resource); + + $allowedOperators = $isScoutMode ? + ['=', 'in', 'not in'] : + ['=', '!=', '>', '>=', '<', '<=', 'like', 'ilike', 'not like', 'in', 'not in']; + + return [ + $attribute.'.field' => [ + 'string', + 'required_without:'.$attribute.'.nested', + $fieldsValidation, + ], + $attribute.'.nested' => ! $isScoutMode ? [ + 'sometimes', + 'prohibits:'.$attribute.'.field,operator,value', + 'array', + ] : [ + 'prohibited', + ], + $attribute.'.nested.*.nested' => [ + 'prohibited', + ], + $attribute.'.nested.*' => [ + (new SearchFilter)->setResource($this->resource), + ], + $attribute.'.operator' => [ + 'string', + Rule::in($allowedOperators), + ], + $attribute.'.type' => ! $isScoutMode ? [ + 'sometimes', + Rule::in(['or', 'and']), + ] : [ + 'prohibited', + ], + $attribute.'.value' => [ + 'exclude_if:'.$attribute.'.value,null', + 'required_without:'.$attribute.'.nested', + ], + ]; + } +} diff --git a/tests/Feature/Rest/MoleculeSearchIlikeTest.php b/tests/Feature/Rest/MoleculeSearchIlikeTest.php new file mode 100644 index 00000000..b10c9587 --- /dev/null +++ b/tests/Feature/Rest/MoleculeSearchIlikeTest.php @@ -0,0 +1,72 @@ +create(); + + Molecule::create([ + 'identifier' => 'CNP9999001.0', + 'name' => 'Caffeine', + 'active' => true, + ]); + + $response = $this->actingAs($user, 'sanctum')->postJson('/api/molecules/search', [ + 'search' => [ + 'filters' => [ + ['field' => 'name', 'operator' => 'ilike', 'value' => '%caffeine%'], + ], + ], + ]); + + $response->assertOk(); + $response->assertJsonPath('data.0.name', 'Caffeine'); + } + + public function test_molecule_search_like_filter_remains_case_sensitive(): void + { + $user = User::factory()->create(); + + Molecule::create([ + 'identifier' => 'CNP9999001.0', + 'name' => 'Caffeine', + 'active' => true, + ]); + + $response = $this->actingAs($user, 'sanctum')->postJson('/api/molecules/search', [ + 'search' => [ + 'filters' => [ + ['field' => 'name', 'operator' => 'like', 'value' => '%caffeine%'], + ], + ], + ]); + + $response->assertOk(); + $response->assertJsonPath('data', []); + } + + public function test_molecule_search_rejects_unknown_filter_operator(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user, 'sanctum')->postJson('/api/molecules/search', [ + 'search' => [ + 'filters' => [ + ['field' => 'name', 'operator' => 'invalid', 'value' => '%caffeine%'], + ], + ], + ]); + + $response->assertUnprocessable(); + } +}