From 479c9ffd1a3e079d77779c35e0857d2a1ac69ddd Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 17:51:46 +0200 Subject: [PATCH 1/2] fix(api): enable ilike filter for REST API search Extend Lomkit search validation to accept the PostgreSQL ilike operator, enabling case-insensitive filtering on REST resource endpoints. Closes #335 --- app/Http/Requests/Rest/SearchRequest.php | 18 +++++ app/Providers/AppServiceProvider.php | 7 +- app/Rules/Search/Search.php | 16 +++++ app/Rules/Search/SearchFilter.php | 60 ++++++++++++++++ .../Feature/Rest/MoleculeSearchIlikeTest.php | 72 +++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 app/Http/Requests/Rest/SearchRequest.php create mode 100644 app/Rules/Search/Search.php create mode 100644 app/Rules/Search/SearchFilter.php create mode 100644 tests/Feature/Rest/MoleculeSearchIlikeTest.php diff --git a/app/Http/Requests/Rest/SearchRequest.php b/app/Http/Requests/Rest/SearchRequest.php new file mode 100644 index 00000000..9d583adc --- /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..11f875e8 --- /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..b1e4a983 --- /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(); + } +} From 25a53d551101699978cb87fa2bf313e099bb9ebf Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:40:51 +0200 Subject: [PATCH 2/2] style: fix Pint violations in REST API search rules Remove empty constructor parentheses and align unary operator spacing so PHP Lint & Security passes on PR #806. --- app/Http/Requests/Rest/SearchRequest.php | 2 +- app/Rules/Search/Search.php | 2 +- app/Rules/Search/SearchFilter.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Requests/Rest/SearchRequest.php b/app/Http/Requests/Rest/SearchRequest.php index 9d583adc..5067dc50 100644 --- a/app/Http/Requests/Rest/SearchRequest.php +++ b/app/Http/Requests/Rest/SearchRequest.php @@ -12,7 +12,7 @@ public function rules(): array $resource = $this->route()->controller::newResource(); return [ - 'search' => (new Search())->setResource($resource), + 'search' => (new Search)->setResource($resource), ]; } } diff --git a/app/Rules/Search/Search.php b/app/Rules/Search/Search.php index 11f875e8..ef4e88be 100644 --- a/app/Rules/Search/Search.php +++ b/app/Rules/Search/Search.php @@ -9,7 +9,7 @@ class Search extends BaseSearch public function buildValidationRules(string $attribute, mixed $value): array { $rules = parent::buildValidationRules($attribute, $value); - $rules[$attribute.'.filters.*'] = (new SearchFilter())->setResource($this->resource); + $rules[$attribute.'.filters.*'] = (new SearchFilter)->setResource($this->resource); return $rules; } diff --git a/app/Rules/Search/SearchFilter.php b/app/Rules/Search/SearchFilter.php index b1e4a983..7187416a 100644 --- a/app/Rules/Search/SearchFilter.php +++ b/app/Rules/Search/SearchFilter.php @@ -16,7 +16,7 @@ public function buildValidationRules(string $attribute, mixed $value): array $fieldsValidation = $isScoutMode ? Rule::in($this->resource->getScoutFields($request)) : - (new ResourceFieldOrNested())->setResource($this->resource); + (new ResourceFieldOrNested)->setResource($this->resource); $allowedOperators = $isScoutMode ? ['=', 'in', 'not in'] : @@ -39,7 +39,7 @@ public function buildValidationRules(string $attribute, mixed $value): array 'prohibited', ], $attribute.'.nested.*' => [ - (new SearchFilter())->setResource($this->resource), + (new SearchFilter)->setResource($this->resource), ], $attribute.'.operator' => [ 'string',