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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:53:34 +02:00
parent 68c5bd4274
commit 6b3517743a
8 changed files with 417 additions and 0 deletions

View File

@@ -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. 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 ## Documentación navegable
``` ```

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\PublicApi\V1;
use App\Support\Search\SiteSearch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* GET /public/v1/search?q=…
*
* What the site shows that matches some words, in the request's language,
* best match first.
*/
class SearchController extends PublicController
{
public function __invoke(Request $request, SiteSearch $search): JsonResponse
{
$data = $request->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()],
]);
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Support\Search;
use App\Models\Call;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Technology;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Searching what the public site shows, in one language.
*
* On Postgres it is full-text search: words are stemmed in the language being
* searched, so "proyectos" finds "proyecto", and results are ranked with the
* title weighing more than the body. Anywhere else — SQLite in the test suite,
* a laptop without Postgres — it falls back to matching the words as written,
* which finds less and ranks crudely but answers the same shape.
*
* Only what is published: a search is a way of reaching the site's pages, and
* a draft has none.
*/
final class SiteSearch
{
/**
* Postgres text search configuration per language. Catalan has no stemmer
* in a stock Postgres, so it is matched word for word.
*
* @var array<string, string>
*/
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<string, array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>}>
*/
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<int, string> $types
* @return Collection<int, array<string, mixed>>
*/
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<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
* @return Collection<int, array<string, mixed>>
*/
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 `<strong>` 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<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
* @return array<string, mixed>
*/
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> $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,
};
}
}

16
public/api-types.d.ts vendored
View File

@@ -268,6 +268,21 @@ export interface PublicRepository {
good_first_issues: PublicIssue[]; 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 { export interface PublicService {
id: number; id: number;
locale: string; 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/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<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>; '/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[]; '/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
'/public/v1/search': PublicSearchResult[];
'/public/v1/projects': PublicProject[]; '/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject; '/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[]; '/public/v1/members': PublicMember[];

View File

@@ -267,6 +267,55 @@ paths:
description: Null applies in every language. description: Null applies in every language.
"503": { $ref: "#/components/responses/Maintenance" } "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: /public/v1/projects:
get: get:
tags: [Public] tags: [Public]
@@ -4342,6 +4391,16 @@ components:
alternates: { $ref: "#/components/schemas/Alternates" } alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true } 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: PublicTechnology:
type: object type: object
properties: properties:

View File

@@ -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\MemberController as PublicMemberController;
use App\Http\Controllers\Api\PublicApi\V1\PageController as PublicPageController; 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\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\ShowcaseController;
use App\Http\Controllers\Api\PublicApi\V1\SiteController; use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; 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('technologies', [SiteController::class, 'technologies'])->name('technologies');
Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues'); Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues');
Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects'); 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', [PublicPageController::class, 'index'])->name('pages.index');
Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show'); Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show');

View File

@@ -117,6 +117,7 @@ class PublicApiContractTest extends TestCase
'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false], 'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false],
'talks' => ['path' => '/public/v1/talks', 'detail' => false], 'talks' => ['path' => '/public/v1/talks', 'detail' => false],
'technologies' => ['path' => '/public/v1/technologies', '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/members/{slug}' => '/public/v1/members/ada-lovelace',
'/public/v1/calls/{slug}' => '/public/v1/calls/backend', '/public/v1/calls/{slug}' => '/public/v1/calls/backend',
'/public/v1/services/{slug}' => '/public/v1/services/desarrollo', '/public/v1/services/{slug}' => '/public/v1/services/desarrollo',
'/public/v1/search' => '/public/v1/search?lang=es&q=proyecto',
]; ];
} }

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Technology;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* GET /public/v1/search — run here on SQLite, so through the word-matching
* fallback; the shape and the rules about what is visible are the same.
*/
class SearchApiTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_finds_published_content_in_the_language_asked(): void
{
Project::factory()->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' => '<p>Sobre <strong>bibliotecas</strong>.</p>'],
]);
$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();
}
}