Module — API HTTP
Tous les endpoints sont définis dans src/server.py (122 lignes). FastAPI, CORS ouvert (allow_origins=["*"]).
Endpoints
| Méthode | Chemin | Rôle |
|---|---|---|
| GET | / | Sanity check (ne touche pas la DB) |
| GET | /travels | Recherche + filtres + pagination + tri |
| GET | /reindex | Déclenche manuellement un reindex (non bloquant) |
| GET | /metrics | Métriques Prometheus (instrumentator) |
| GET | /.well-known/assetlinks.json | Android App Links (static file) |
| GET | /docs, /redoc, /openapi.json | Swagger UI (uniquement si WITH_DOCS=true) |
GET /
Sanity check. Renvoie toujours 200.
{
"app-name": "better-search",
"datetime": "2026-05-12T11:22:33+02:00",
"version-tag": "3.2.0-staging.1",
"build-date": "2026-05-11T14:18:00Z"
}datetimeest enEurope/Zurich.version-tagetbuild-dateviennent deos.getenv("VERSION_TAG")/BUILD_DATE, injectés audocker buildvia--build-arg. Si vides →null.- Ne valide pas la connexion DB. Pour un check liveness DB, voir
pg_isreadycôté infra.
GET /travels
Endpoint principal. Combinaison de filtres, recherche, tri, pagination. Aucun param n'est obligatoire.
Query params
| Param | Type | Défaut | Notes |
|---|---|---|---|
search | string (max_length=500) | None | Active la branche hybride si non vide |
destination | string | None | Code ISO-2 majuscule (ex CH) |
category | string | None | Slugs séparés par , — OR |
dates | string | None | YYYY-MM-DD_YYYY-MM-DD, séparés par , — OR |
hide_invalid | bool | None | true filtre is_valid=TRUE |
discountclub | bool | None | Tri-état (true/false/absent) |
seaside | bool | None | Tri-état |
page | int (ge=1) | 1 | |
size | int (ge=1, le=100) | 8 | |
infoDensity | min | card | search | full | None | Si None, retourne le raw_data entier |
orderBy | rating | departure | duration | None | Ignoré si search non vide |
orderDirection | asc | desc | asc | |
rrfVectorWeight | float (ge=0.0, le=1.0) | env var | Override pondération RRF |
semanticFloor | float (ge=0.0, le=1.0) | env var | Override plancher cosinus |
tsvectorFloor | float (ge=0.0) | env var | Override plancher ts_rank_cd |
Sémantique de chaque filtre : domain/filter-semantics.md.
Réponse
{
"totalRecords": 42, // count total après filtres + planchers
"totalFilteredRecords": 8, // taille de la page courante
"page": 1,
"records": [
{ "id": "…", "name": "…", "_relevance_score": 0.0162,
"semantic_score": 0.78, "tsvector_score": 0.12, "…": "…" }
]
}_relevance_score: score RRF en branche hybride,1.0en branche filtre-seul.semantic_score: similarité cosinus (1 - vector_distance).0.0en branche filtre-seul.tsvector_score:ts_rank_cd.0.0en branche filtre-seul.- Forme de
records[i]: dépend deinfoDensity(voir ci-dessous).
infoDensity
apply_info_density() dans src/_utils.py. Quatre niveaux :
min
{
"id": "…",
"name": "…",
"slug": "…",
"_relevance_score": 0.016,
"semantic_score": 0.78,
"tsvector_score": 0.12
}card
{
"id": "…",
"name": "…",
"_relevance_score": 0.016,
"semantic_score": 0.78,
"tsvector_score": 0.12,
"description": "…",
"rating": 4.5,
"slug": "…",
"nextDeparture": "2026-01-23T00:00:00"
}search
Approche « équivalent JSON des colonnes générées » :
{
"id": "…",
"name": "…",
"destination": "Autriche", // country.name (PAS le code !)
"discount_club": false,
"is_valid": true,
"travel_ranges": ["ski", "autocar-4"],
"departure_dates": ["2026-01-23T00:00:00"],
"_relevance_score": 0.016,
"semantic_score": 0.78,
"tsvector_score": 0.12
}⚠️ Incohérence connue :
infoDensity=searchexposedestinationcomme nom du pays (country.name), alors que le filtre?destination=attend un code ISO-2. Ne pas binder bidirectionnellement.
Absent ou full
Retourne le raw_data complet (tous les champs Horizon : photos, occurrences détaillées, prix, etc.) + les trois scores. C'est le défaut.
Tri
- Avec
?search=: tri sur_relevance_score DESC.orderByignoré. - Sans
?search=:orderBy=rating→(raw_data->>'commentsAverageRating')::NUMERICorderBy=departure→next_departureorderBy=duration→(raw_data->>'duration')::NUMERIC- Autre / non précisé → pas de tri explicite (ordre de scan)
Tous les tris ajoutent NULLS LAST.
Logging
Quand ?search= est non vide et non blanc, le serveur logge en JSON :
{
"search": "ski alpes",
"destination": "CH",
"category": "ski",
"dates": "2026-01-01_2026-01-31",
"discountclub": null,
"seaside": null,
"n_results": 42
}Pratique pour grep des requêtes utilisateur en prod. Voir modules/monitoring.md.
Erreurs typiques
422(FastAPI) — param hors bornes (size > 100,rrfVectorWeight > 1, etc.).500— date mal formée en?dates=(parse dedatetime.fromisoformatéchoue).500— Infomaniak down et?search=non vide. Pas de fallback : la branche FTS pure n'est pas tentée.200avecrecords: []— aucun voyage ne passe les filtres + planchers. C'est le cas nominal d'une recherche sans résultat.
GET /reindex
Déclenche un reindex en arrière-plan. Réponse immédiate.
{
"message": "Reindexing started",
"time": "2026-05-12T11:22:33+02:00"
}- Toujours 200, même si un reindex est déjà en cours. Le double-trigger est silencieusement no-op (cf. lock global,
architecture/reindex-lifecycle.md). - Pas d'authentification. L'endpoint est ouvert. À protéger côté reverse proxy si exposé au public (typiquement réservé au réseau interne).
GET /metrics
Exposé par prometheus-fastapi-instrumentator. Métriques détaillées : modules/monitoring.md.
À protéger côté reverse proxy si exposition publique (pas authentifié au niveau de l'app).
GET /.well-known/assetlinks.json
Fichier statique servi via StaticFiles(directory=".well-known") (cf. app.py ligne 26).
Contenu actuel :
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "ch.buchard.app",
"sha256_cert_fingerprints": ["B4:FF:3D:AC:…"]
}
}]Sert à enregistrer l'app Android ch.buchard.app comme handler des URLs (Android App Links). Si le SHA-256 du keystore Android change, ce fichier doit être mis à jour et redéployé.
Swagger / OpenAPI
Activé conditionnellement :
_with_docs = getenv("WITH_DOCS", "false").lower() == "true"
app = FastAPI(
docs_url="/docs" if _with_docs else None,
redoc_url="/redoc" if _with_docs else None,
openapi_url="/openapi.json" if _with_docs else None,
)- En dev (Compose
WITH_DOCS=true) : accessible. - En staging/prod : par défaut désactivé. Activer via
WITH_DOCS=truesi on veut publier la doc en interne.
CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost", "http://localhost:5173", "*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)Ouvert à *. À tightener pour la prod publique (limiter aux domaines buchard.ch et compagnons).

