From 6b3517743a2a42db51eb68755873a4c6976274f4 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 08:53:34 +0200 Subject: [PATCH] Search the public site through the API Full-text on Postgres, stemmed in the language searched; word matching elsewhere. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++ .../Api/PublicApi/V1/SearchController.php | 41 ++++ app/Support/Search/SiteSearch.php | 201 ++++++++++++++++++ public/api-types.d.ts | 16 ++ public/openapi.yaml | 59 +++++ routes/api.php | 2 + tests/Feature/Api/PublicApiContractTest.php | 2 + tests/Feature/Api/SearchApiTest.php | 80 +++++++ 8 files changed, 417 insertions(+) create mode 100644 app/Http/Controllers/Api/PublicApi/V1/SearchController.php create mode 100644 app/Support/Search/SiteSearch.php create mode 100644 tests/Feature/Api/SearchApiTest.php diff --git a/README.md b/README.md index a61c1e9..075365a 100644 --- a/README.md +++ b/README.md @@ -1348,6 +1348,22 @@ cliente en vez de comprobar nombres de rol. Quien no tenga `pages.publish` solo ve páginas publicadas en el listado. +### Búsqueda + +`GET /api/public/v1/search?q=…` busca en lo que la web enseña —páginas, +proyectos y servicios publicados, convocatorias abiertas, miembros públicos y +tecnologías en uso— en el idioma de la petición, lo mejor primero, con el `path` +de cada resultado en la web. + +En Postgres es **búsqueda de texto completo**: las palabras se reducen a su raíz +en el idioma que se busca («proyectos» encuentra «proyecto»; el catalán, sin +diccionario en un Postgres de serie, se compara palabra a palabra), el título +pesa más que el cuerpo y las etiquetas del texto enriquecido no cuentan como +palabras. En cualquier otra base —el SQLite de los tests— cae a comparar las +palabras tal cual: encuentra menos y ordena a grandes rasgos, pero responde con +la misma forma. Los tests cubren esa segunda rama; la de Postgres no tiene base +contra la que correr en la suite. + ## Documentación navegable ``` diff --git a/app/Http/Controllers/Api/PublicApi/V1/SearchController.php b/app/Http/Controllers/Api/PublicApi/V1/SearchController.php new file mode 100644 index 0000000..2f08cec --- /dev/null +++ b/app/Http/Controllers/Api/PublicApi/V1/SearchController.php @@ -0,0 +1,41 @@ +validate([ + 'q' => ['required', 'string', 'min:2', 'max:120'], + 'type' => ['sometimes', 'array'], + 'type.*' => [Rule::in(SiteSearch::TYPES)], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:50'], + ]); + + $results = $search->search( + $data['q'], + app()->getLocale(), + $data['type'] ?? SiteSearch::TYPES, + (int) ($data['limit'] ?? 20), + ); + + return response()->json([ + 'data' => $results, + 'meta' => ['query' => $data['q'], 'locale' => app()->getLocale(), 'count' => $results->count()], + ]); + } +} diff --git a/app/Support/Search/SiteSearch.php b/app/Support/Search/SiteSearch.php new file mode 100644 index 0000000..4d62b05 --- /dev/null +++ b/app/Support/Search/SiteSearch.php @@ -0,0 +1,201 @@ + + */ + protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english']; + + /** + * Type => what is searched in it: the title field, the fields with + * weight B, and whether those fields are translatable. + * + * @var array, title: string, translatedTitle: bool, text: array}> + */ + protected const SOURCES = [ + 'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']], + 'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']], + 'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']], + 'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']], + 'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']], + 'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']], + ]; + + public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology']; + + /** + * @param array $types + * @return Collection> + */ + public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection + { + $query = trim($query); + + if (mb_strlen($query) < 2) { + return collect(); + } + + $postgres = DB::connection()->getDriverName() === 'pgsql'; + + return collect(self::SOURCES) + ->only($types) + ->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit)) + ->sortByDesc('score') + ->take($limit) + ->values(); + } + + /** + * @param array{model: class-string, title: string, translatedTitle: bool, text: array} $source + * @return Collection> + */ + protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection + { + $builder = $this->visible($source['model']::query(), $source['model']); + + $title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title']; + $text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres)); + + if ($postgres) { + $dictionary = self::DICTIONARIES[$locale] ?? 'simple'; + // Tags out of rich text, so `` is not a word anyone finds. + $body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || "); + $document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')"; + $tsquery = "websearch_to_tsquery('{$dictionary}', ?)"; + + $builder + ->select('*') + ->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query]) + ->whereRaw("{$document} @@ {$tsquery}", [$query]) + ->orderByDesc('search_score'); + } else { + $words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%'); + + foreach ($words as $word) { + $builder->where(function (Builder $q) use ($title, $text, $word): void { + $q->whereRaw("lower({$title}) like ?", [$word]); + + foreach ($text as $sql) { + $q->orWhereRaw("lower({$sql}) like ?", [$word]); + } + }); + } + } + + return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale)); + } + + /** + * @param array{model: class-string, title: string, translatedTitle: bool, text: array} $source + * @return array + */ + protected function result(string $type, array $source, Model $record, string $query, string $locale): array + { + $title = $source['translatedTitle'] + ? (string) $record->getTranslation($source['title'], $locale, false) + : (string) $record->getAttribute($source['title']); + + $text = collect($source['text']) + ->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false)) + ->implode(' '); + + // Crude where Postgres did not rank: a title match counts more. + $score = $record->getAttribute('search_score') + ?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0); + + return [ + 'type' => $type, + 'id' => $record->getKey(), + 'title' => $title, + 'excerpt' => $this->excerpt($text, $query), + 'path' => $this->path($type, $record, $locale), + 'score' => round((float) $score, 4), + ]; + } + + /** + * Published, open or public, whichever each kind calls it. + * + * @param class-string $model + */ + protected function visible(Builder $query, string $model): Builder + { + return match ($model) { + Call::class => $query->open(), + Member::class => $query->public(), + // A technology is shown on the site once something uses it. + Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()), + default => $query->published(), + }; + } + + /** + * The text in one language of a translatable JSON column. + */ + protected function json(string $field, string $locale, bool $postgres): string + { + return $postgres + ? "({$field}->>'{$locale}')" + : "json_extract({$field}, '$.{$locale}')"; + } + + /** + * Some words either side of the first match, out of plain text. + */ + protected function excerpt(string $html, string $query): string + { + $text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html)))); + $first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query; + $at = mb_stripos($text, (string) $first); + + if ($at === false || $at < 60) { + return Str::limit($text, 180); + } + + return '…'.Str::limit(mb_substr($text, $at - 60), 180); + } + + protected function path(string $type, Model $record, string $locale): ?string + { + return match ($type) { + 'member' => SiteUrls::path('member', $locale, (string) $record->slug), + // The site gives technologies pages, but this side has no address + // for them: the slug is enough to build one. + 'technology' => null, + default => SiteUrls::alternates($record, $type)[$locale] ?? null, + }; + } +} diff --git a/public/api-types.d.ts b/public/api-types.d.ts index 557a531..0e0f866 100644 --- a/public/api-types.d.ts +++ b/public/api-types.d.ts @@ -268,6 +268,21 @@ export interface PublicRepository { good_first_issues: PublicIssue[]; } +export interface PublicSearchResult { + type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology'; + id: number; + title: string; + /** + * Plain text around the first match. + */ + excerpt: string; + path?: string; + /** + * Higher is a better match. Only comparable within one response. + */ + score: number; +} + export interface PublicService { id: number; locale: string; @@ -382,6 +397,7 @@ export interface PublicApiPayloads { '/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } }; '/public/v1/slugs': Record })[]>; '/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[]; + '/public/v1/search': PublicSearchResult[]; '/public/v1/projects': PublicProject[]; '/public/v1/projects/{slug}': PublicProject; '/public/v1/members': PublicMember[]; diff --git a/public/openapi.yaml b/public/openapi.yaml index d9fe6a1..2f4baad 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -267,6 +267,55 @@ paths: description: Null applies in every language. "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/search: + get: + tags: [Public] + summary: Search the site + description: | + What the site shows that matches some words, in the request's + language, best match first: published pages, projects and services, + open calls, public members, and technologies in use. + + Full-text in production — words are stemmed in the language searched, + so "proyectos" finds "proyecto", and a title match ranks above one in + the body. `path` is where the result lives on the site; null for a + technology, which has no address on this side. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + - name: q + in: query + required: true + schema: { type: string, minLength: 2, maxLength: 120 } + - name: type[] + in: query + description: "Limit to some kinds: `page`, `project`, `service`, `call`, `member`, `technology`." + schema: + type: array + items: { type: string, enum: [page, project, service, call, member, technology] } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 50, default: 20 } + responses: + "200": + description: Results + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PublicSearchResult" } + meta: + type: object + properties: + query: { type: string } + locale: { type: string } + count: { type: integer } + "422": { $ref: "#/components/responses/ValidationError" } + "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/projects: get: tags: [Public] @@ -4342,6 +4391,16 @@ components: alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } + PublicSearchResult: + type: object + properties: + type: { type: string, enum: [page, project, service, call, member, technology] } + id: { type: integer } + title: { type: string } + excerpt: { type: string, description: Plain text around the first match. } + path: { type: string, nullable: true, example: /proyectos/mi-proyecto } + score: { type: number, description: Higher is a better match. Only comparable within one response. } + PublicTechnology: type: object properties: diff --git a/routes/api.php b/routes/api.php index 7800d53..bec7a26 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\PublicApi\V1\CallController as PublicCallController use App\Http\Controllers\Api\PublicApi\V1\MemberController as PublicMemberController; use App\Http\Controllers\Api\PublicApi\V1\PageController as PublicPageController; use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectController; +use App\Http\Controllers\Api\PublicApi\V1\SearchController; use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController; use App\Http\Controllers\Api\PublicApi\V1\SiteController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; @@ -84,6 +85,7 @@ Route::prefix('public/v1') Route::get('technologies', [SiteController::class, 'technologies'])->name('technologies'); Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues'); Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects'); + Route::get('search', SearchController::class)->name('search'); Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index'); Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show'); diff --git a/tests/Feature/Api/PublicApiContractTest.php b/tests/Feature/Api/PublicApiContractTest.php index 7eb7c9a..2e615ad 100644 --- a/tests/Feature/Api/PublicApiContractTest.php +++ b/tests/Feature/Api/PublicApiContractTest.php @@ -117,6 +117,7 @@ class PublicApiContractTest extends TestCase 'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false], 'talks' => ['path' => '/public/v1/talks', 'detail' => false], 'technologies' => ['path' => '/public/v1/technologies', 'detail' => false], + 'search' => ['path' => '/public/v1/search', 'detail' => true], ]; } @@ -133,6 +134,7 @@ class PublicApiContractTest extends TestCase '/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace', '/public/v1/calls/{slug}' => '/public/v1/calls/backend', '/public/v1/services/{slug}' => '/public/v1/services/desarrollo', + '/public/v1/search' => '/public/v1/search?lang=es&q=proyecto', ]; } diff --git a/tests/Feature/Api/SearchApiTest.php b/tests/Feature/Api/SearchApiTest.php new file mode 100644 index 0000000..c05b69d --- /dev/null +++ b/tests/Feature/Api/SearchApiTest.php @@ -0,0 +1,80 @@ +published()->create([ + 'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'], + 'slug' => ['es' => 'catalogo', 'en' => 'catalogue'], + 'tagline' => ['es' => 'Una herramienta para bibliotecas.', 'en' => 'A tool for libraries.'], + ]); + Page::factory()->published()->create([ + 'title' => ['es' => 'Bibliotecas libres'], + 'slug' => ['es' => 'bibliotecas'], + 'body' => ['es' => '

Sobre bibliotecas.

'], + ]); + + $response = $this->getJson('/api/public/v1/search?lang=es&q=bibliotecas') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('meta.count', 2); + + // The title match first. + $response->assertJsonPath('data.0.type', 'page') + ->assertJsonPath('data.0.path', '/bibliotecas'); + + $this->getJson('/api/public/v1/search?q=libraries&lang=en') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.title', 'Open catalogue') + ->assertJsonPath('data.0.path', '/en/projects/catalogue'); + } + + #[Test] + public function drafts_and_private_people_are_not_found(): void + { + Page::factory()->draft()->create(['title' => ['es' => 'Borrador secreto'], 'slug' => ['es' => 'borrador']]); + Member::factory()->create(['name' => 'Secreto Oculto', 'slug' => 'oculto', 'is_public' => false]); + Technology::factory()->create(['name' => 'Secretlang']); + + $this->getJson('/api/public/v1/search?lang=es&q=secret')->assertOk()->assertJsonCount(0, 'data'); + } + + #[Test] + public function every_word_has_to_match_and_types_can_be_narrowed(): void + { + Project::factory()->published()->create(['title' => ['es' => 'Radar técnico'], 'slug' => ['es' => 'radar']]); + Page::factory()->published()->create(['title' => ['es' => 'Radar de tecnologías'], 'slug' => ['es' => 'radar-pagina']]); + + $this->getJson('/api/public/v1/search?lang=es&q=radar+técnico')->assertJsonCount(1, 'data'); + $this->getJson('/api/public/v1/search?lang=es&q=radar&type[]=page') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'page'); + } + + #[Test] + public function a_query_is_required(): void + { + $this->getJson('/api/public/v1/search')->assertUnprocessable()->assertJsonValidationErrors('q'); + $this->getJson('/api/public/v1/search?q=a')->assertUnprocessable(); + } +}