diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php index 492022a..f676aca 100644 --- a/app/Enums/PackageType.php +++ b/app/Enums/PackageType.php @@ -8,6 +8,8 @@ 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'; /** * 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..254b43e --- /dev/null +++ b/app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php @@ -0,0 +1,34 @@ + + */ + public function __invoke(PackageSearchRequest $request): array + { + $results = $this->searchService->search($request); + + return PackageSearchResponse::from($results)->toArray(); + } +} diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php new file mode 100644 index 0000000..b8a7aee --- /dev/null +++ b/app/Services/Packages/PackageSearchService.php @@ -0,0 +1,128 @@ + Relations to eager load to avoid N+1 queries when building FAIR metadata. */ + private const EAGER_LOAD = ['releases', 'authors', 'tags', 'metas']; + + /** + * Search packages by type with optional query and version requirements. + * + * @return LengthAwarePaginator + */ + public function search(PackageSearchRequest $request): LengthAwarePaginator + { + if ($request->q === null || $request->q === '') { + $query = Package::with(self::EAGER_LOAD) + ->where('type', $request->type) + ->orderByDesc('created_at'); + + $this->applyRequiresFilter($query, $request); + + return $query->paginate(perPage: $request->per_page, page: $request->page); + } + + // Try full-text search first + $results = $this->fullTextSearch($request); + + if ($results->total() > 0) { + return $results; + } + + // Fall back to trigram similarity + return $this->trigramSearch($request); + } + + /** + * 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', ?)"; + + $query = 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', ?)", [$request->q]); + }); + }) + ->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]); + + $this->applyRequiresFilter($query, $request); + + return $query->paginate(perPage: $request->per_page, page: $request->page); + } + + /** + * 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) + ->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]); + + $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. + * + * 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 + */ + 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 new file mode 100644 index 0000000..38eabb0 --- /dev/null +++ b/app/Values/Packages/PackageSearchRequest.php @@ -0,0 +1,57 @@ +|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, + ) {} + + /** + * 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 + { + $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'], + ]); + + 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/app/Values/Packages/PackageSearchResponse.php b/app/Values/Packages/PackageSearchResponse.php new file mode 100644 index 0000000..515a773 --- /dev/null +++ b/app/Values/Packages/PackageSearchResponse.php @@ -0,0 +1,50 @@ +> $packages + */ + public function __construct( + public array $info, + public array $packages, + ) {} + + /** + * Transform a paginator of Package models into the response structure. + * + * Each package is converted to its FAIR metadata representation. + * + * @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(), + ]; + } +} diff --git a/database/factories/PackageFactory.php b/database/factories/PackageFactory.php index 69b6f3b..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']); + $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']); @@ -41,6 +41,11 @@ public function definition(): array /** * Configure the model factory to create a plugin with tags */ + public function typo3Extension(): static + { + return $this->state(['type' => 'typo3-extension', 'origin' => 'fair']); + } + public function withTags(int $count = 3): static { return $this->afterCreating(function (Package $package) use ($count) { @@ -61,10 +66,7 @@ public function withSpecificTags(array $tagNames): static return PackageTag::query()->firstOrCreate( ['slug' => $slug], - [ - 'id' => $this->faker->uuid(), - 'name' => $tagName, - ], + ['name' => $tagName], ); }); diff --git a/database/factories/PackageReleaseFactory.php b/database/factories/PackageReleaseFactory.php index 6453bae..322022c 100644 --- a/database/factories/PackageReleaseFactory.php +++ b/database/factories/PackageReleaseFactory.php @@ -19,8 +19,7 @@ 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']), + 'php' => $this->faker->randomElement(['8.0', '8.1', '8.2', '8.3']), ], 'suggests' => [ 'another-plugin' => $this->faker->semver(), @@ -38,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/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 @@ +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..c95dfa3 --- /dev/null +++ b/database/seeders/Typo3ExtensionSeeder.php @@ -0,0 +1,52 @@ +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 = Package::factory() + ->withAuthors() + ->withMetas() + ->withSpecificTags($tags) + ->typo3Extension() + ->create($ext); + + $package->releases()->createMany( + PackageReleaseFactory::new() + ->typo3() + ->count(2) + ->make(['package_id' => $package->id]) + ->toArray(), + ); + } + } +} diff --git a/routes/api.php b/routes/api.php index f17a758..269f7cb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\API\Elastic\ElasticSearchController; use App\Http\Controllers\API\FAIR\Packages\PackageInformationController; +use App\Http\Controllers\API\FAIR\Packages\PackageSearchController; use App\Http\Controllers\API\Metrics\MetricsController; use App\Http\Controllers\API\WpOrg\Core\BrowseHappyController; use App\Http\Controllers\API\WpOrg\Core\ImportersController; @@ -37,11 +38,15 @@ ->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..cbed670 --- /dev/null +++ b/tests/Feature/API/FAIR/PackageSearchControllerTest.php @@ -0,0 +1,250 @@ +withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->create(['name' => 'Awesome Gallery', 'slug' => 'awesome-gallery']); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->create(['name' => 'Simple Form', 'slug' => 'simple-form']); + + $this->getJson('/packages/typo3-extension?q=gallery') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Awesome Gallery'); +}); + +it('searches packages by description', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->create([ + 'name' => 'Alpha Extension', + 'slug' => 'alpha-extension', + 'description' => 'A powerful image optimization tool', + ]); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->create([ + 'name' => 'Beta Extension', + 'slug' => 'beta-extension', + 'description' => 'A simple contact form builder', + ]); + + $this->getJson('/packages/typo3-extension?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']) + ->typo3Extension() + ->create(['name' => 'SEO Master', 'slug' => 'seo-master']); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->withSpecificTags(['gallery', 'media']) + ->typo3Extension() + ->create(['name' => 'Photo Viewer', 'slug' => 'photo-viewer']); + + $this->getJson('/packages/typo3-extension?q=marketing') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'SEO Master'); +}); + +it('filters by type', function () { + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->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-extension') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.type', 'typo3-extension'); + + $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() + ->typo3Extension() + ->create(['name' => 'Old Package', 'slug' => 'old-package', 'created_at' => now()->subDays(10)]); + + Package::factory() + ->withAuthors() + ->withReleases() + ->withMetas() + ->typo3Extension() + ->create(['name' => 'New Package', 'slug' => 'new-package', 'created_at' => now()]); + + $response = $this->getJson('/packages/typo3-extension') + ->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() + ->typo3Extension() + ->create(); + + $this->getJson('/packages/typo3-extension?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-extension?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() + ->typo3Extension() + ->create(['name' => 'Test Package', 'slug' => 'test-package']); + + $this->getJson('/packages/typo3-extension') + ->assertOk() + ->assertJsonStructure([ + 'info' => ['page', 'per_page', 'total', 'pages'], + 'packages' => [ + '*' => [ + '@context', + 'id', + 'type', + 'license', + 'authors', + 'releases', + 'slug', + 'name', + ], + ], + ]); +}); + +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() + ->assertJsonPath('info.total', 0) + ->assertJsonPath('info.pages', 1) + ->assertJsonCount(0, 'packages'); +});