From 9fe46722d5ae76ebe24e9800c9da63f5fc286b1f Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 14:34:52 +0100 Subject: [PATCH 01/13] Add TYPO3 extension support and package search endpoint Extend the FAIR package system with a typo3-plugin package type and a new GET /packages/{type} search endpoint with full-text search, tag matching, trigram fallback, and pagination. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Enums/PackageType.php | 1 + .../FAIR/Packages/PackageSearchController.php | 45 +++++ .../Packages/PackageSearchService.php | 57 ++++++ database/factories/PackageFactory.php | 7 +- ..._000000_add_search_indexes_to_packages.php | 38 ++++ routes/api.php | 7 +- .../API/FAIR/PackageSearchControllerTest.php | 191 ++++++++++++++++++ 7 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php create mode 100644 app/Services/Packages/PackageSearchService.php create mode 100644 database/migrations/2026_03_20_000000_add_search_indexes_to_packages.php create mode 100644 tests/Feature/API/FAIR/PackageSearchControllerTest.php diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php index 492022a..b347a69 100644 --- a/app/Enums/PackageType.php +++ b/app/Enums/PackageType.php @@ -8,6 +8,7 @@ enum PackageType: string case CORE = 'wp-core'; case PLUGIN = 'wp-plugin'; case THEME = 'wp-theme'; + case TYPO3_PLUGIN = 'typo3-plugin'; /** * Get the list of package type values. diff --git a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php new file mode 100644 index 0000000..96c19bc --- /dev/null +++ b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php @@ -0,0 +1,45 @@ +validate([ + 'q' => ['nullable', 'string', 'max:200'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $page = (int) ($validated['page'] ?? 1); + $perPage = (int) ($validated['per_page'] ?? 24); + + $results = $this->searchService->search($type, $validated['q'] ?? null, $page, $perPage); + + $packages = collect($results->items())->map( + fn ($package) => FairMetadata::from($package)->toArray() + ); + + return response()->json([ + 'info' => [ + 'page' => $results->currentPage(), + 'per_page' => $results->perPage(), + 'total' => $results->total(), + 'pages' => $results->lastPage(), + ], + 'packages' => $packages, + ]); + } +} diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php new file mode 100644 index 0000000..ec0c576 --- /dev/null +++ b/app/Services/Packages/PackageSearchService.php @@ -0,0 +1,57 @@ +orderByDesc('created_at') + ->paginate(perPage: $perPage, page: $page); + } + + // Try full-text search first + $results = $this->fullTextSearch($type, $query, $page, $perPage); + + if ($results->total() > 0) { + return $results; + } + + // Fall back to trigram similarity + return $this->trigramSearch($type, $query, $page, $perPage); + } + + private function fullTextSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator + { + $tsQuery = "plainto_tsquery('english', ?)"; + + return Package::where('type', $type) + ->where(function ($q) use ($tsQuery, $query) { + $q->whereRaw("search_vector @@ {$tsQuery}", [$query]) + ->orWhereExists(function ($sub) use ($query) { + $sub->select(DB::raw(1)) + ->from('package_package_tag') + ->join('package_tags', 'package_tags.id', '=', 'package_package_tag.package_tag_id') + ->whereColumn('package_package_tag.package_id', 'packages.id') + ->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$query]); + }); + }) + ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$query]) + ->paginate(perPage: $perPage, page: $page); + } + + private function trigramSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator + { + return Package::where('type', $type) + ->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$query, $query]) + ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$query, $query]) + ->paginate(perPage: $perPage, page: $page); + } +} diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index 69b6f3b..ea6785e 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -20,7 +20,7 @@ public function definition(): array $did = 'fake:' . $this->faker->slug(); $name = $this->faker->words(3, true); $slug = Str::slug($name); - $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core']); + $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-plugin']); $origin = $this->faker->randomElement(['fair', 'wp']); $license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']); @@ -41,6 +41,11 @@ public function definition(): array /** * Configure the model factory to create a plugin with tags */ + public function typo3Plugin(): static + { + return $this->state(['type' => 'typo3-plugin', 'origin' => 'fair']); + } + public function withTags(int $count = 3): static { return $this->afterCreating(function (Package $package) use ($count) { diff --git a/database/migrations/2026_03_20_000000_add_search_indexes_to_packages.php b/database/migrations/2026_03_20_000000_add_search_indexes_to_packages.php new file mode 100644 index 0000000..0dc61b8 --- /dev/null +++ b/database/migrations/2026_03_20_000000_add_search_indexes_to_packages.php @@ -0,0 +1,38 @@ +name('api.metrics'); //// FAIR metadata + $router->get('/packages/{type}', PackageSearchController::class) + ->where('type', implode('|', \App\Enums\PackageType::values())) + ->name('package.search'); + $router->get('/packages/{did}', [PackageInformationController::class, 'fairMetadata']) ->name('package.fairMetadata'); $router->get('/packages/{type}/{slug}/did.json', [PackageInformationController::class, 'didDocument']) - ->where('type', 'wp-plugin|wp-theme|wp-core') + ->where('type', implode('|', \App\Enums\PackageType::values())) ->name('package.didDocument'); Route::get('/plugins/search', [ElasticSearchController::class, 'searchPlugins']); diff --git a/tests/Feature/API/FAIR/PackageSearchControllerTest.php b/tests/Feature/API/FAIR/PackageSearchControllerTest.php new file mode 100644 index 0000000..bd22c8a --- /dev/null +++ b/tests/Feature/API/FAIR/PackageSearchControllerTest.php @@ -0,0 +1,191 @@ +withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'Awesome Gallery', 'slug' => 'awesome-gallery']); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'Simple Form', 'slug' => 'simple-form']); + + $this->getJson('/packages/typo3-plugin?q=gallery') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Awesome Gallery'); +}); + +it('searches packages by description', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create([ + 'name' => 'Alpha Extension', + 'slug' => 'alpha-extension', + 'description' => 'A powerful image optimization tool', + ]); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create([ + 'name' => 'Beta Extension', + 'slug' => 'beta-extension', + 'description' => 'A simple contact form builder', + ]); + + $this->getJson('/packages/typo3-plugin?q=optimization') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Alpha Extension'); +}); + +it('searches packages by tag name', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->withSpecificTags(['seo', 'marketing']) + ->typo3Plugin() + ->create(['name' => 'SEO Master', 'slug' => 'seo-master']); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->withSpecificTags(['gallery', 'media']) + ->typo3Plugin() + ->create(['name' => 'Photo Viewer', 'slug' => 'photo-viewer']); + + $this->getJson('/packages/typo3-plugin?q=marketing') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'SEO Master'); +}); + +it('filters by type', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'TYPO3 Extension', 'slug' => 'typo3-extension']); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->create(['name' => 'WP Plugin', 'slug' => 'wp-plugin-test', 'type' => 'wp-plugin', 'origin' => 'wp']); + + $this->getJson('/packages/typo3-plugin') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.type', 'typo3-plugin'); + + $this->getJson('/packages/wp-plugin') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.type', 'wp-plugin'); +}); + +it('returns all packages of type when no query, newest first', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'Old Package', 'slug' => 'old-package', 'created_at' => now()->subDays(10)]); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'New Package', 'slug' => 'new-package', 'created_at' => now()]); + + $response = $this->getJson('/packages/typo3-plugin') + ->assertOk() + ->assertJsonCount(2, 'packages'); + + expect($response->json('packages.0.name'))->toBe('New Package'); + expect($response->json('packages.1.name'))->toBe('Old Package'); +}); + +it('paginates results', function () { + Package::factory(30) + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(); + + $this->getJson('/packages/typo3-plugin?per_page=10&page=1') + ->assertOk() + ->assertJsonPath('info.page', 1) + ->assertJsonPath('info.per_page', 10) + ->assertJsonPath('info.total', 30) + ->assertJsonPath('info.pages', 3) + ->assertJsonCount(10, 'packages'); + + $this->getJson('/packages/typo3-plugin?per_page=10&page=3') + ->assertOk() + ->assertJsonPath('info.page', 3) + ->assertJsonCount(10, 'packages'); +}); + +it('returns 404 for invalid type', function () { + $this->getJson('/packages/invalid-type') + ->assertNotFound(); +}); + +it('returns FAIR metadata structure', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Plugin() + ->create(['name' => 'Test Package', 'slug' => 'test-package']); + + $this->getJson('/packages/typo3-plugin') + ->assertOk() + ->assertJsonStructure([ + 'info' => ['page', 'per_page', 'total', 'pages'], + 'packages' => [ + '*' => [ + '@context', + 'id', + 'type', + 'license', + 'authors', + 'releases', + 'slug', + 'name', + ], + ], + ]); +}); + +it('returns empty packages array with zero total when no results', function () { + $this->getJson('/packages/typo3-plugin?q=nonexistent') + ->assertOk() + ->assertJsonPath('info.total', 0) + ->assertJsonPath('info.pages', 1) + ->assertJsonCount(0, 'packages'); +}); From 725b1ffab4e8d9c9a4e094b239d954e147b4f2e7 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 14:37:34 +0100 Subject: [PATCH 02/13] Add generic type annotations to LengthAwarePaginator return types Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Services/Packages/PackageSearchService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index ec0c576..daeb5a3 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -9,6 +9,7 @@ class PackageSearchService { + /** @return LengthAwarePaginator */ public function search(string $type, ?string $query, int $page = 1, int $perPage = 24): LengthAwarePaginator { if ($query === null || $query === '') { @@ -28,6 +29,7 @@ public function search(string $type, ?string $query, int $page = 1, int $perPage return $this->trigramSearch($type, $query, $page, $perPage); } + /** @return LengthAwarePaginator */ private function fullTextSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator { $tsQuery = "plainto_tsquery('english', ?)"; @@ -47,6 +49,7 @@ private function fullTextSearch(string $type, string $query, int $page, int $per ->paginate(perPage: $perPage, page: $page); } + /** @return LengthAwarePaginator */ private function trigramSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator { return Package::where('type', $type) From 6395a1fd01a853c82da032b7c8cfcb12ff71ea52 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 15:03:05 +0100 Subject: [PATCH 03/13] Fix eager loading and PackageTag mass assignment in search Eager load relations (releases, authors, tags, metas) in search queries to avoid lazy loading violations. Remove explicit id from withSpecificTags factory since HasUuids auto-generates it. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Services/Packages/PackageSearchService.php | 11 ++++++++--- database/factories/PackageFactory.php | 5 +---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index daeb5a3..fbc4bb5 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -12,8 +12,11 @@ class PackageSearchService /** @return LengthAwarePaginator */ public function search(string $type, ?string $query, int $page = 1, int $perPage = 24): LengthAwarePaginator { + $eagerLoad = ['releases', 'authors', 'tags', 'metas']; + if ($query === null || $query === '') { - return Package::where('type', $type) + return Package::with($eagerLoad) + ->where('type', $type) ->orderByDesc('created_at') ->paginate(perPage: $perPage, page: $page); } @@ -34,7 +37,8 @@ private function fullTextSearch(string $type, string $query, int $page, int $per { $tsQuery = "plainto_tsquery('english', ?)"; - return Package::where('type', $type) + return Package::with(['releases', 'authors', 'tags', 'metas']) + ->where('type', $type) ->where(function ($q) use ($tsQuery, $query) { $q->whereRaw("search_vector @@ {$tsQuery}", [$query]) ->orWhereExists(function ($sub) use ($query) { @@ -52,7 +56,8 @@ private function fullTextSearch(string $type, string $query, int $page, int $per /** @return LengthAwarePaginator */ private function trigramSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator { - return Package::where('type', $type) + return Package::with(['releases', 'authors', 'tags', 'metas']) + ->where('type', $type) ->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$query, $query]) ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$query, $query]) ->paginate(perPage: $perPage, page: $page); diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index ea6785e..99bcf92 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -66,10 +66,7 @@ public function withSpecificTags(array $tagNames): static return PackageTag::query()->firstOrCreate( ['slug' => $slug], - [ - 'id' => $this->faker->uuid(), - 'name' => $tagName, - ], + ['name' => $tagName], ); }); From 9afdaafb354086315d0354249776b45b2c73e5d1 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 15:14:05 +0100 Subject: [PATCH 04/13] Rename typo3-plugin to typo3-extension Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Enums/PackageType.php | 2 +- database/factories/PackageFactory.php | 6 +-- .../API/FAIR/PackageSearchControllerTest.php | 42 +++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php index b347a69..26ee42d 100644 --- a/app/Enums/PackageType.php +++ b/app/Enums/PackageType.php @@ -8,7 +8,7 @@ enum PackageType: string case CORE = 'wp-core'; case PLUGIN = 'wp-plugin'; case THEME = 'wp-theme'; - case TYPO3_PLUGIN = 'typo3-plugin'; + case TYPO3_EXTENSION = 'typo3-extension'; /** * Get the list of package type values. diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index 99bcf92..c67f740 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -20,7 +20,7 @@ public function definition(): array $did = 'fake:' . $this->faker->slug(); $name = $this->faker->words(3, true); $slug = Str::slug($name); - $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-plugin']); + $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-extension']); $origin = $this->faker->randomElement(['fair', 'wp']); $license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']); @@ -41,9 +41,9 @@ public function definition(): array /** * Configure the model factory to create a plugin with tags */ - public function typo3Plugin(): static + public function typo3Extension(): static { - return $this->state(['type' => 'typo3-plugin', 'origin' => 'fair']); + return $this->state(['type' => 'typo3-extension', 'origin' => 'fair']); } public function withTags(int $count = 3): static diff --git a/tests/Feature/API/FAIR/PackageSearchControllerTest.php b/tests/Feature/API/FAIR/PackageSearchControllerTest.php index bd22c8a..7aa0b90 100644 --- a/tests/Feature/API/FAIR/PackageSearchControllerTest.php +++ b/tests/Feature/API/FAIR/PackageSearchControllerTest.php @@ -12,17 +12,17 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'Awesome Gallery', 'slug' => 'awesome-gallery']); Package::factory() ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'Simple Form', 'slug' => 'simple-form']); - $this->getJson('/packages/typo3-plugin?q=gallery') + $this->getJson('/packages/typo3-extension?q=gallery') ->assertOk() ->assertJsonCount(1, 'packages') ->assertJsonPath('packages.0.name', 'Awesome Gallery'); @@ -33,7 +33,7 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create([ 'name' => 'Alpha Extension', 'slug' => 'alpha-extension', @@ -44,14 +44,14 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create([ 'name' => 'Beta Extension', 'slug' => 'beta-extension', 'description' => 'A simple contact form builder', ]); - $this->getJson('/packages/typo3-plugin?q=optimization') + $this->getJson('/packages/typo3-extension?q=optimization') ->assertOk() ->assertJsonCount(1, 'packages') ->assertJsonPath('packages.0.name', 'Alpha Extension'); @@ -63,7 +63,7 @@ ->withReleases() ->withMetas() ->withSpecificTags(['seo', 'marketing']) - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'SEO Master', 'slug' => 'seo-master']); Package::factory() @@ -71,10 +71,10 @@ ->withReleases() ->withMetas() ->withSpecificTags(['gallery', 'media']) - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'Photo Viewer', 'slug' => 'photo-viewer']); - $this->getJson('/packages/typo3-plugin?q=marketing') + $this->getJson('/packages/typo3-extension?q=marketing') ->assertOk() ->assertJsonCount(1, 'packages') ->assertJsonPath('packages.0.name', 'SEO Master'); @@ -85,7 +85,7 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'TYPO3 Extension', 'slug' => 'typo3-extension']); Package::factory() @@ -94,10 +94,10 @@ ->withMetas() ->create(['name' => 'WP Plugin', 'slug' => 'wp-plugin-test', 'type' => 'wp-plugin', 'origin' => 'wp']); - $this->getJson('/packages/typo3-plugin') + $this->getJson('/packages/typo3-extension') ->assertOk() ->assertJsonCount(1, 'packages') - ->assertJsonPath('packages.0.type', 'typo3-plugin'); + ->assertJsonPath('packages.0.type', 'typo3-extension'); $this->getJson('/packages/wp-plugin') ->assertOk() @@ -110,17 +110,17 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'Old Package', 'slug' => 'old-package', 'created_at' => now()->subDays(10)]); Package::factory() ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'New Package', 'slug' => 'new-package', 'created_at' => now()]); - $response = $this->getJson('/packages/typo3-plugin') + $response = $this->getJson('/packages/typo3-extension') ->assertOk() ->assertJsonCount(2, 'packages'); @@ -133,10 +133,10 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(); - $this->getJson('/packages/typo3-plugin?per_page=10&page=1') + $this->getJson('/packages/typo3-extension?per_page=10&page=1') ->assertOk() ->assertJsonPath('info.page', 1) ->assertJsonPath('info.per_page', 10) @@ -144,7 +144,7 @@ ->assertJsonPath('info.pages', 3) ->assertJsonCount(10, 'packages'); - $this->getJson('/packages/typo3-plugin?per_page=10&page=3') + $this->getJson('/packages/typo3-extension?per_page=10&page=3') ->assertOk() ->assertJsonPath('info.page', 3) ->assertJsonCount(10, 'packages'); @@ -160,10 +160,10 @@ ->withAuthors() ->withReleases() ->withMetas() - ->typo3Plugin() + ->typo3Extension() ->create(['name' => 'Test Package', 'slug' => 'test-package']); - $this->getJson('/packages/typo3-plugin') + $this->getJson('/packages/typo3-extension') ->assertOk() ->assertJsonStructure([ 'info' => ['page', 'per_page', 'total', 'pages'], @@ -183,7 +183,7 @@ }); it('returns empty packages array with zero total when no results', function () { - $this->getJson('/packages/typo3-plugin?q=nonexistent') + $this->getJson('/packages/typo3-extension?q=nonexistent') ->assertOk() ->assertJsonPath('info.total', 0) ->assertJsonPath('info.pages', 1) From 43dc998cce52031e2d3060d1ce00c9262c820057 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 15:55:38 +0100 Subject: [PATCH 05/13] Add TYPO3 extension seeder with sample data 10 realistic TYPO3 extensions with tags for local testing. Runs as part of db:seed and skips if TYPO3 extensions already exist. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- database/seeders/DatabaseSeeder.php | 1 + database/seeders/Typo3ExtensionSeeder.php | 43 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 database/seeders/Typo3ExtensionSeeder.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 74872e3..b8dff37 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,5 +12,6 @@ public function run(): void $this->call(AuthorizationSeeder::class); $this->call(UserSeeder::class); $this->call(PluginSeeder::class); + $this->call(Typo3ExtensionSeeder::class); } } diff --git a/database/seeders/Typo3ExtensionSeeder.php b/database/seeders/Typo3ExtensionSeeder.php new file mode 100644 index 0000000..c7aa796 --- /dev/null +++ b/database/seeders/Typo3ExtensionSeeder.php @@ -0,0 +1,43 @@ +exists()) { + return; + } + + $extensions = [ + ['name' => 'News System', 'slug' => 'news', 'description' => 'Versatile news system based on Extbase and Fluid. Editor friendly, built-in support for categories, tags, and related articles.', 'tags' => ['news', 'blog', 'articles']], + ['name' => 'Powermail', 'slug' => 'powermail', 'description' => 'All-in-one form builder for TYPO3. Create contact forms, surveys, and registration forms with drag and drop.', 'tags' => ['forms', 'contact', 'email']], + ['name' => 'RealURL', 'slug' => 'realurl', 'description' => 'Speaking URLs for TYPO3. Transforms ugly query parameters into human-readable paths for better SEO.', 'tags' => ['seo', 'urls', 'routing']], + ['name' => 'Solr for TYPO3', 'slug' => 'solr', 'description' => 'Apache Solr integration for TYPO3. Enterprise search with faceting, suggestions, and relevance tuning.', 'tags' => ['search', 'indexing', 'enterprise']], + ['name' => 'Mask', 'slug' => 'mask', 'description' => 'Create custom content elements and page properties without writing a single line of code. Visual backend editor.', 'tags' => ['content', 'editor', 'backend']], + ['name' => 'Image Gallery', 'slug' => 'gallery', 'description' => 'Responsive image gallery with lightbox support. Grid, masonry and slider layouts for media collections.', 'tags' => ['images', 'gallery', 'media']], + ['name' => 'SEO Basics', 'slug' => 'cs-seo', 'description' => 'Essential SEO tools for TYPO3. Meta tags, Open Graph, structured data, sitemap generation, and canonical URLs.', 'tags' => ['seo', 'meta', 'sitemap']], + ['name' => 'Flux', 'slug' => 'flux', 'description' => 'Fluid templating engine integration. Build flexible page layouts and content elements using Fluid templates.', 'tags' => ['templates', 'fluid', 'layout']], + ['name' => 'Scheduler Tasks', 'slug' => 'scheduler', 'description' => 'Cron-like task scheduler for TYPO3. Automate recurring jobs like imports, cleanups, and notifications.', 'tags' => ['automation', 'cron', 'tasks']], + ['name' => 'Secure Downloads', 'slug' => 'secure-downloads', 'description' => 'Protect file downloads with access control. Track downloads, restrict by user group, and log access.', 'tags' => ['security', 'files', 'downloads']], + ]; + + foreach ($extensions as $ext) { + $tags = $ext['tags']; + unset($ext['tags']); + + Package::factory() + ->withAuthors() + ->withReleases(2) + ->withMetas() + ->withSpecificTags($tags) + ->typo3Extension() + ->create($ext); + } + } +} From 5cf20a412b570f31d26db9affab5361dabf77f39 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 16:01:06 +0100 Subject: [PATCH 06/13] Remove WordPress-specific 'wp' from PackageReleaseFactory requires The requires field now only includes 'php', making the factory generic for all package types instead of WordPress-specific. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- database/factories/PackageReleaseFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/database/factories/PackageReleaseFactory.php b/database/factories/PackageReleaseFactory.php index 6453bae..205b0d0 100644 --- a/database/factories/PackageReleaseFactory.php +++ b/database/factories/PackageReleaseFactory.php @@ -19,7 +19,6 @@ public function definition(): array 'version' => $this->faker->semver(), 'download_url' => $this->faker->url(), 'requires' => [ - 'wp' => $this->faker->semver(), 'php' => $this->faker->randomElement(['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']), ], 'suggests' => [ From 725160b67be722f5ece88af7c38c74ee1da5351c Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 16:03:29 +0100 Subject: [PATCH 07/13] Add TYPO3 version requirements to release factory and seeder Add typo3() state to PackageReleaseFactory that sets requires with typo3 version (11.5/12.4/13.4) and PHP 8.1+. Use it in the TYPO3 extension seeder for realistic test data. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- database/factories/PackageReleaseFactory.php | 12 +++++++++++- database/seeders/Typo3ExtensionSeeder.php | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/database/factories/PackageReleaseFactory.php b/database/factories/PackageReleaseFactory.php index 205b0d0..322022c 100644 --- a/database/factories/PackageReleaseFactory.php +++ b/database/factories/PackageReleaseFactory.php @@ -19,7 +19,7 @@ public function definition(): array 'version' => $this->faker->semver(), 'download_url' => $this->faker->url(), 'requires' => [ - 'php' => $this->faker->randomElement(['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']), + 'php' => $this->faker->randomElement(['8.0', '8.1', '8.2', '8.3']), ], 'suggests' => [ 'another-plugin' => $this->faker->semver(), @@ -37,4 +37,14 @@ public function definition(): array ], ]; } + + public function typo3(): static + { + return $this->state(fn () => [ + 'requires' => [ + 'typo3' => $this->faker->randomElement(['11.5', '12.4', '13.4']), + 'php' => $this->faker->randomElement(['8.1', '8.2', '8.3', '8.4']), + ], + ]); + } } diff --git a/database/seeders/Typo3ExtensionSeeder.php b/database/seeders/Typo3ExtensionSeeder.php index c7aa796..c95dfa3 100644 --- a/database/seeders/Typo3ExtensionSeeder.php +++ b/database/seeders/Typo3ExtensionSeeder.php @@ -4,6 +4,8 @@ namespace Database\Seeders; use App\Models\Package; +use App\Models\PackageRelease; +use Database\Factories\PackageReleaseFactory; use Illuminate\Database\Seeder; class Typo3ExtensionSeeder extends Seeder @@ -31,13 +33,20 @@ public function run(): void $tags = $ext['tags']; unset($ext['tags']); - Package::factory() + $package = Package::factory() ->withAuthors() - ->withReleases(2) ->withMetas() ->withSpecificTags($tags) ->typo3Extension() ->create($ext); + + $package->releases()->createMany( + PackageReleaseFactory::new() + ->typo3() + ->count(2) + ->make(['package_id' => $package->id]) + ->toArray(), + ); } } } From 05c3e924d4b53c362b67d19ba889f4aa21a7817c Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 16:11:08 +0100 Subject: [PATCH 08/13] Add typo3-core and typo3-theme package types Aligns with the FAIR protocol extension registry (fairpm/fair-protocol#63) which defines all three TYPO3 types: typo3-core, typo3-extension, typo3-theme. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Enums/PackageType.php | 2 ++ database/factories/PackageFactory.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php index 26ee42d..011f2f0 100644 --- a/app/Enums/PackageType.php +++ b/app/Enums/PackageType.php @@ -8,7 +8,9 @@ enum PackageType: string case CORE = 'wp-core'; case PLUGIN = 'wp-plugin'; case THEME = 'wp-theme'; + case TYPO3_CORE = 'typo3-core'; case TYPO3_EXTENSION = 'typo3-extension'; + case TYPO3_THEME = 'typo3-theme'; /** * Get the list of package type values. diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index c67f740..cfefa5d 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -20,7 +20,7 @@ public function definition(): array $did = 'fake:' . $this->faker->slug(); $name = $this->faker->words(3, true); $slug = Str::slug($name); - $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-extension']); + $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-core', 'typo3-extension', 'typo3-theme']); $origin = $this->faker->randomElement(['fair', 'wp']); $license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']); From 2194d6a095e1628e9231b328702479d4ae002c45 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 16:12:33 +0100 Subject: [PATCH 09/13] Remove typo3-theme package type (not yet specified) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- app/Enums/PackageType.php | 1 - database/factories/PackageFactory.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php index 011f2f0..f676aca 100644 --- a/app/Enums/PackageType.php +++ b/app/Enums/PackageType.php @@ -10,7 +10,6 @@ enum PackageType: string case THEME = 'wp-theme'; case TYPO3_CORE = 'typo3-core'; case TYPO3_EXTENSION = 'typo3-extension'; - case TYPO3_THEME = 'typo3-theme'; /** * Get the list of package type values. diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index cfefa5d..8e786a6 100644 --- a/database/factories/PackageFactory.php +++ b/database/factories/PackageFactory.php @@ -20,7 +20,7 @@ public function definition(): array $did = 'fake:' . $this->faker->slug(); $name = $this->faker->words(3, true); $slug = Str::slug($name); - $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-core', 'typo3-extension', 'typo3-theme']); + $type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-core', 'typo3-extension']); $origin = $this->faker->randomElement(['fair', 'wp']); $license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']); From 9023d1bcb0e149117c3859888e39e9b6eae91f6b Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 16:57:24 +0100 Subject: [PATCH 10/13] Refactor search to use PackageSearchRequest DTO Replace inline Laravel validation with a beacon-hq/bag DTO, keeping the controller and service in sync as the protocol evolves. Uses #[FromRouteParameter] for the type param and #[StripExtraParameters] to match existing project conventions. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- .../FAIR/Packages/PackageSearchController.php | 15 ++---- .../Packages/PackageSearchService.php | 49 ++++++++++--------- app/Values/Packages/PackageSearchRequest.php | 40 +++++++++++++++ 3 files changed, 68 insertions(+), 36 deletions(-) create mode 100644 app/Values/Packages/PackageSearchRequest.php diff --git a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php index 96c19bc..4702adb 100644 --- a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php +++ b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php @@ -6,8 +6,8 @@ use App\Http\Controllers\Controller; use App\Services\Packages\PackageSearchService; use App\Values\Packages\FairMetadata; +use App\Values\Packages\PackageSearchRequest; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; class PackageSearchController extends Controller { @@ -15,18 +15,9 @@ public function __construct( private PackageSearchService $searchService, ) {} - public function __invoke(Request $request, string $type): JsonResponse + public function __invoke(PackageSearchRequest $request): JsonResponse { - $validated = $request->validate([ - 'q' => ['nullable', 'string', 'max:200'], - 'page' => ['nullable', 'integer', 'min:1'], - 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], - ]); - - $page = (int) ($validated['page'] ?? 1); - $perPage = (int) ($validated['per_page'] ?? 24); - - $results = $this->searchService->search($type, $validated['q'] ?? null, $page, $perPage); + $results = $this->searchService->search($request); $packages = collect($results->items())->map( fn ($package) => FairMetadata::from($package)->toArray() diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index fbc4bb5..df18cd8 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -4,62 +4,63 @@ namespace App\Services\Packages; use App\Models\Package; +use App\Values\Packages\PackageSearchRequest; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; class PackageSearchService { + private const EAGER_LOAD = ['releases', 'authors', 'tags', 'metas']; + /** @return LengthAwarePaginator */ - public function search(string $type, ?string $query, int $page = 1, int $perPage = 24): LengthAwarePaginator + public function search(PackageSearchRequest $request): LengthAwarePaginator { - $eagerLoad = ['releases', 'authors', 'tags', 'metas']; - - if ($query === null || $query === '') { - return Package::with($eagerLoad) - ->where('type', $type) + if ($request->q === null || $request->q === '') { + return Package::with(self::EAGER_LOAD) + ->where('type', $request->type) ->orderByDesc('created_at') - ->paginate(perPage: $perPage, page: $page); + ->paginate(perPage: $request->per_page, page: $request->page); } // Try full-text search first - $results = $this->fullTextSearch($type, $query, $page, $perPage); + $results = $this->fullTextSearch($request); if ($results->total() > 0) { return $results; } // Fall back to trigram similarity - return $this->trigramSearch($type, $query, $page, $perPage); + return $this->trigramSearch($request); } /** @return LengthAwarePaginator */ - private function fullTextSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator + private function fullTextSearch(PackageSearchRequest $request): LengthAwarePaginator { $tsQuery = "plainto_tsquery('english', ?)"; - return Package::with(['releases', 'authors', 'tags', 'metas']) - ->where('type', $type) - ->where(function ($q) use ($tsQuery, $query) { - $q->whereRaw("search_vector @@ {$tsQuery}", [$query]) - ->orWhereExists(function ($sub) use ($query) { + return Package::with(self::EAGER_LOAD) + ->where('type', $request->type) + ->where(function ($q) use ($tsQuery, $request) { + $q->whereRaw("search_vector @@ {$tsQuery}", [$request->q]) + ->orWhereExists(function ($sub) use ($request) { $sub->select(DB::raw(1)) ->from('package_package_tag') ->join('package_tags', 'package_tags.id', '=', 'package_package_tag.package_tag_id') ->whereColumn('package_package_tag.package_id', 'packages.id') - ->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$query]); + ->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$request->q]); }); }) - ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$query]) - ->paginate(perPage: $perPage, page: $page); + ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]) + ->paginate(perPage: $request->per_page, page: $request->page); } /** @return LengthAwarePaginator */ - private function trigramSearch(string $type, string $query, int $page, int $perPage): LengthAwarePaginator + private function trigramSearch(PackageSearchRequest $request): LengthAwarePaginator { - return Package::with(['releases', 'authors', 'tags', 'metas']) - ->where('type', $type) - ->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$query, $query]) - ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$query, $query]) - ->paginate(perPage: $perPage, page: $page); + return Package::with(self::EAGER_LOAD) + ->where('type', $request->type) + ->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$request->q, $request->q]) + ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$request->q, $request->q]) + ->paginate(perPage: $request->per_page, page: $request->page); } } diff --git a/app/Values/Packages/PackageSearchRequest.php b/app/Values/Packages/PackageSearchRequest.php new file mode 100644 index 0000000..b299b05 --- /dev/null +++ b/app/Values/Packages/PackageSearchRequest.php @@ -0,0 +1,40 @@ + */ + #[Transforms(Request::class)] + public static function fromRequest(Request $request): array + { + $validated = $request->validate([ + 'q' => ['nullable', 'string', 'max:200'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + return [ + 'type' => $request->route('type'), + 'q' => $validated['q'] ?? null, + 'page' => (int) ($validated['page'] ?? 1), + 'per_page' => (int) ($validated['per_page'] ?? 24), + ]; + } +} From 6520323880c8af67fb871ad30deecd6dde5006e4 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 17:09:59 +0100 Subject: [PATCH 11/13] Add requires version filter to package search Filter packages by platform version via ?requires[typo3]=12.4 or ?requires[php]=8.1. Matches packages with at least one release whose required version is <= the specified version, using Postgres jsonb and array comparison for dotted version strings. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- .../Packages/PackageSearchService.php | 54 ++++++++++++++--- app/Values/Packages/PackageSearchRequest.php | 5 ++ .../API/FAIR/PackageSearchControllerTest.php | 59 +++++++++++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index df18cd8..b4fa893 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -6,6 +6,7 @@ use App\Models\Package; use App\Values\Packages\PackageSearchRequest; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class PackageSearchService @@ -16,10 +17,13 @@ class PackageSearchService public function search(PackageSearchRequest $request): LengthAwarePaginator { if ($request->q === null || $request->q === '') { - return Package::with(self::EAGER_LOAD) + $query = Package::with(self::EAGER_LOAD) ->where('type', $request->type) - ->orderByDesc('created_at') - ->paginate(perPage: $request->per_page, page: $request->page); + ->orderByDesc('created_at'); + + $this->applyRequiresFilter($query, $request); + + return $query->paginate(perPage: $request->per_page, page: $request->page); } // Try full-text search first @@ -38,7 +42,7 @@ private function fullTextSearch(PackageSearchRequest $request): LengthAwarePagin { $tsQuery = "plainto_tsquery('english', ?)"; - return Package::with(self::EAGER_LOAD) + $query = Package::with(self::EAGER_LOAD) ->where('type', $request->type) ->where(function ($q) use ($tsQuery, $request) { $q->whereRaw("search_vector @@ {$tsQuery}", [$request->q]) @@ -50,17 +54,49 @@ private function fullTextSearch(PackageSearchRequest $request): LengthAwarePagin ->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$request->q]); }); }) - ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]) - ->paginate(perPage: $request->per_page, page: $request->page); + ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]); + + $this->applyRequiresFilter($query, $request); + + return $query->paginate(perPage: $request->per_page, page: $request->page); } /** @return LengthAwarePaginator */ private function trigramSearch(PackageSearchRequest $request): LengthAwarePaginator { - return Package::with(self::EAGER_LOAD) + $query = Package::with(self::EAGER_LOAD) ->where('type', $request->type) ->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$request->q, $request->q]) - ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$request->q, $request->q]) - ->paginate(perPage: $request->per_page, page: $request->page); + ->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$request->q, $request->q]); + + $this->applyRequiresFilter($query, $request); + + return $query->paginate(perPage: $request->per_page, page: $request->page); + } + + /** + * Filter packages to those having at least one release compatible with the given requirements. + * e.g. ?requires[typo3]=12.4 finds packages with a release requiring typo3 <= 12.4 + * + * @param Builder $query + */ + private function applyRequiresFilter(Builder $query, PackageSearchRequest $request): void + { + if (empty($request->requires)) { + return; + } + + $query->whereExists(function ($sub) use ($request) { + $sub->select(DB::raw(1)) + ->from('package_releases') + ->whereColumn('package_releases.package_id', 'packages.id'); + + foreach ($request->requires as $key => $version) { + $sub->whereRaw( + "package_releases.requires->>? IS NOT NULL AND string_to_array(package_releases.requires->>?, '.')::int[] <= string_to_array(?, '.')::int[]", + [$key, $key, $version], + ); + } + }); } } diff --git a/app/Values/Packages/PackageSearchRequest.php b/app/Values/Packages/PackageSearchRequest.php index b299b05..362fe68 100644 --- a/app/Values/Packages/PackageSearchRequest.php +++ b/app/Values/Packages/PackageSearchRequest.php @@ -12,10 +12,12 @@ #[StripExtraParameters] readonly class PackageSearchRequest extends DTO { + /** @param array|null $requires */ public function __construct( #[FromRouteParameter] public string $type, public ?string $q = null, + public ?array $requires = null, public int $page = 1, public int $per_page = 24, ) {} @@ -26,6 +28,8 @@ public static function fromRequest(Request $request): array { $validated = $request->validate([ 'q' => ['nullable', 'string', 'max:200'], + 'requires' => ['nullable', 'array'], + 'requires.*' => ['string', 'max:20'], 'page' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]); @@ -33,6 +37,7 @@ public static function fromRequest(Request $request): array return [ 'type' => $request->route('type'), 'q' => $validated['q'] ?? null, + 'requires' => $validated['requires'] ?? null, 'page' => (int) ($validated['page'] ?? 1), 'per_page' => (int) ($validated['per_page'] ?? 24), ]; diff --git a/tests/Feature/API/FAIR/PackageSearchControllerTest.php b/tests/Feature/API/FAIR/PackageSearchControllerTest.php index 7aa0b90..cbed670 100644 --- a/tests/Feature/API/FAIR/PackageSearchControllerTest.php +++ b/tests/Feature/API/FAIR/PackageSearchControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); use App\Models\Package; +use Database\Factories\PackageReleaseFactory; beforeEach(function () { Package::truncate(); @@ -182,6 +183,64 @@ ]); }); +it('filters by requires version', function () { + // Package requiring TYPO3 11.5 + $old = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'Old Extension', 'slug' => 'old-ext']); + $old->releases()->createMany( + PackageReleaseFactory::new()->count(1)->make([ + 'package_id' => $old->id, + 'requires' => ['typo3' => '11.5', 'php' => '8.1'], + ])->toArray(), + ); + + // Package requiring TYPO3 13.4 + $new = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'New Extension', 'slug' => 'new-ext']); + $new->releases()->createMany( + PackageReleaseFactory::new()->count(1)->make([ + 'package_id' => $new->id, + 'requires' => ['typo3' => '13.4', 'php' => '8.2'], + ])->toArray(), + ); + + // Filter for TYPO3 12.4 — only the 11.5 package qualifies + $this->getJson('/packages/typo3-extension?requires[typo3]=12.4') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Old Extension'); + + // Filter for TYPO3 13.4 — both qualify + $this->getJson('/packages/typo3-extension?requires[typo3]=13.4') + ->assertOk() + ->assertJsonCount(2, 'packages'); +}); + +it('filters by requires version combined with search', function () { + $match = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'Gallery Pro', 'slug' => 'gallery-pro']); + $match->releases()->createMany( + PackageReleaseFactory::new()->count(1)->make([ + 'package_id' => $match->id, + 'requires' => ['typo3' => '12.4', 'php' => '8.1'], + ])->toArray(), + ); + + $tooNew = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'Gallery Ultra', 'slug' => 'gallery-ultra']); + $tooNew->releases()->createMany( + PackageReleaseFactory::new()->count(1)->make([ + 'package_id' => $tooNew->id, + 'requires' => ['typo3' => '13.4', 'php' => '8.3'], + ])->toArray(), + ); + + $this->getJson('/packages/typo3-extension?q=gallery&requires[typo3]=12.4') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Gallery Pro'); +}); + it('returns empty packages array with zero total when no results', function () { $this->getJson('/packages/typo3-extension?q=nonexistent') ->assertOk() From d55b3d6536c3b156180e3bfc1a77dde574dc0475 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 17:14:59 +0100 Subject: [PATCH 12/13] Use PackageSearchResponse DTO for search output Replace inline response()->json() with a PackageSearchResponse DTO that transforms the paginator into the response structure, keeping both input and output using the beacon-hq/bag DTO pattern. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- .../FAIR/Packages/PackageSearchController.php | 20 ++------- app/Values/Packages/PackageSearchResponse.php | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 app/Values/Packages/PackageSearchResponse.php diff --git a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php index 4702adb..cb1713a 100644 --- a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php +++ b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php @@ -5,9 +5,8 @@ use App\Http\Controllers\Controller; use App\Services\Packages\PackageSearchService; -use App\Values\Packages\FairMetadata; use App\Values\Packages\PackageSearchRequest; -use Illuminate\Http\JsonResponse; +use App\Values\Packages\PackageSearchResponse; class PackageSearchController extends Controller { @@ -15,22 +14,11 @@ public function __construct( private PackageSearchService $searchService, ) {} - public function __invoke(PackageSearchRequest $request): JsonResponse + /** @return array */ + public function __invoke(PackageSearchRequest $request): array { $results = $this->searchService->search($request); - $packages = collect($results->items())->map( - fn ($package) => FairMetadata::from($package)->toArray() - ); - - return response()->json([ - 'info' => [ - 'page' => $results->currentPage(), - 'per_page' => $results->perPage(), - 'total' => $results->total(), - 'pages' => $results->lastPage(), - ], - 'packages' => $packages, - ]); + return PackageSearchResponse::from($results)->toArray(); } } diff --git a/app/Values/Packages/PackageSearchResponse.php b/app/Values/Packages/PackageSearchResponse.php new file mode 100644 index 0000000..c8d880f --- /dev/null +++ b/app/Values/Packages/PackageSearchResponse.php @@ -0,0 +1,41 @@ +> $packages + */ + public function __construct( + public array $info, + public array $packages, + ) {} + + /** + * @param LengthAwarePaginator $paginator + * @return array + */ + #[Transforms(LengthAwarePaginator::class)] + public static function fromPaginator(LengthAwarePaginator $paginator): array + { + return [ + 'info' => [ + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'pages' => $paginator->lastPage(), + ], + 'packages' => collect($paginator->items())->map( + fn (Package $package) => FairMetadata::from($package)->toArray() + )->all(), + ]; + } +} From d1fb348321acdc775e95be7e09b2b552f71770bb Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Fri, 20 Mar 2026 17:18:24 +0100 Subject: [PATCH 13/13] Add phpdoc to search controller, service, and DTOs Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joost de Valk --- .../FAIR/Packages/PackageSearchController.php | 12 ++++++- .../Packages/PackageSearchService.php | 34 ++++++++++++++++--- app/Values/Packages/PackageSearchRequest.php | 14 +++++++- app/Values/Packages/PackageSearchResponse.php | 9 +++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php index cb1713a..254b43e 100644 --- a/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php +++ b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php @@ -8,13 +8,23 @@ use App\Values\Packages\PackageSearchRequest; use App\Values\Packages\PackageSearchResponse; +/** + * Search and browse packages by type. + * + * Handles GET /packages/{type} with optional full-text search, version + * filtering, and pagination. Returns FAIR metadata for each matching package. + */ class PackageSearchController extends Controller { public function __construct( private PackageSearchService $searchService, ) {} - /** @return array */ + /** + * Execute the search and return paginated FAIR metadata results. + * + * @return array + */ public function __invoke(PackageSearchRequest $request): array { $results = $this->searchService->search($request); diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index b4fa893..b8a7aee 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -9,11 +9,23 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; +/** + * Searches packages using Postgres full-text search with trigram fallback. + * + * Search strategy with a query: try tsvector full-text search (including tag matching) + * first, fall back to trigram similarity on name/slug if no FTS results. + * Without a query: return all packages of the given type, newest first. + */ class PackageSearchService { + /** @var list Relations to eager load to avoid N+1 queries when building FAIR metadata. */ private const EAGER_LOAD = ['releases', 'authors', 'tags', 'metas']; - /** @return LengthAwarePaginator */ + /** + * Search packages by type with optional query and version requirements. + * + * @return LengthAwarePaginator + */ public function search(PackageSearchRequest $request): LengthAwarePaginator { if ($request->q === null || $request->q === '') { @@ -37,7 +49,13 @@ public function search(PackageSearchRequest $request): LengthAwarePaginator return $this->trigramSearch($request); } - /** @return LengthAwarePaginator */ + /** + * Search using Postgres tsvector full-text search, ranked by ts_rank. + * + * Also matches packages whose tags match the query via a subquery join. + * + * @return LengthAwarePaginator + */ private function fullTextSearch(PackageSearchRequest $request): LengthAwarePaginator { $tsQuery = "plainto_tsquery('english', ?)"; @@ -61,7 +79,13 @@ private function fullTextSearch(PackageSearchRequest $request): LengthAwarePagin return $query->paginate(perPage: $request->per_page, page: $request->page); } - /** @return LengthAwarePaginator */ + /** + * Fallback search using pg_trgm trigram similarity on name and slug. + * + * Used when full-text search returns no results, to catch fuzzy/partial matches. + * + * @return LengthAwarePaginator + */ private function trigramSearch(PackageSearchRequest $request): LengthAwarePaginator { $query = Package::with(self::EAGER_LOAD) @@ -76,7 +100,9 @@ private function trigramSearch(PackageSearchRequest $request): LengthAwarePagina /** * Filter packages to those having at least one release compatible with the given requirements. - * e.g. ?requires[typo3]=12.4 finds packages with a release requiring typo3 <= 12.4 + * + * Compares dotted version strings as integer arrays, e.g. ?requires[typo3]=12.4 finds + * packages with a release requiring typo3 <= 12.4. Multiple requirements are ANDed together. * * @param Builder $query */ diff --git a/app/Values/Packages/PackageSearchRequest.php b/app/Values/Packages/PackageSearchRequest.php index 362fe68..38eabb0 100644 --- a/app/Values/Packages/PackageSearchRequest.php +++ b/app/Values/Packages/PackageSearchRequest.php @@ -9,6 +9,12 @@ use Bag\Attributes\Transforms; use Illuminate\Http\Request; +/** + * Validated search request for the GET /packages/{type} endpoint. + * + * Constructed automatically from the HTTP request via Bag's service provider. + * The `type` route parameter is resolved via #[FromRouteParameter]. + */ #[StripExtraParameters] readonly class PackageSearchRequest extends DTO { @@ -22,7 +28,13 @@ public function __construct( public int $per_page = 24, ) {} - /** @return array */ + /** + * Transform an incoming HTTP request into constructor arguments. + * + * Validates query parameters and extracts the package type from the route. + * + * @return array + */ #[Transforms(Request::class)] public static function fromRequest(Request $request): array { diff --git a/app/Values/Packages/PackageSearchResponse.php b/app/Values/Packages/PackageSearchResponse.php index c8d880f..515a773 100644 --- a/app/Values/Packages/PackageSearchResponse.php +++ b/app/Values/Packages/PackageSearchResponse.php @@ -8,6 +8,11 @@ use Bag\Attributes\Transforms; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +/** + * Response DTO for the package search endpoint. + * + * Wraps paginated results with pagination info and FAIR metadata for each package. + */ readonly class PackageSearchResponse extends DTO { /** @@ -20,6 +25,10 @@ public function __construct( ) {} /** + * Transform a paginator of Package models into the response structure. + * + * Each package is converted to its FAIR metadata representation. + * * @param LengthAwarePaginator $paginator * @return array */