Skip to content

Module — API HTTP

Tous les endpoints sont définis dans src/server.py (122 lignes). FastAPI, CORS ouvert (allow_origins=["*"]).

Endpoints

MéthodeCheminRôle
GET/Sanity check (ne touche pas la DB)
GET/travelsRecherche + filtres + pagination + tri
GET/reindexDéclenche manuellement un reindex (non bloquant)
GET/metricsMétriques Prometheus (instrumentator)
GET/.well-known/assetlinks.jsonAndroid App Links (static file)
GET/docs, /redoc, /openapi.jsonSwagger UI (uniquement si WITH_DOCS=true)

GET /

Sanity check. Renvoie toujours 200.

json
{
  "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"
}
  • datetime est en Europe/Zurich.
  • version-tag et build-date viennent de os.getenv("VERSION_TAG") / BUILD_DATE, injectés au docker build via --build-arg. Si vides → null.
  • Ne valide pas la connexion DB. Pour un check liveness DB, voir pg_isready côté infra.

GET /travels

Endpoint principal. Combinaison de filtres, recherche, tri, pagination. Aucun param n'est obligatoire.

Query params

ParamTypeDéfautNotes
searchstring (max_length=500)NoneActive la branche hybride si non vide
destinationstringNoneCode ISO-2 majuscule (ex CH)
categorystringNoneSlugs séparés par , — OR
datesstringNoneYYYY-MM-DD_YYYY-MM-DD, séparés par , — OR
hide_invalidboolNonetrue filtre is_valid=TRUE
discountclubboolNoneTri-état (true/false/absent)
seasideboolNoneTri-état
pageint (ge=1)1
sizeint (ge=1, le=100)8
infoDensitymin | card | search | fullNoneSi None, retourne le raw_data entier
orderByrating | departure | durationNoneIgnoré si search non vide
orderDirectionasc | descasc
rrfVectorWeightfloat (ge=0.0, le=1.0)env varOverride pondération RRF
semanticFloorfloat (ge=0.0, le=1.0)env varOverride plancher cosinus
tsvectorFloorfloat (ge=0.0)env varOverride plancher ts_rank_cd

Sémantique de chaque filtre : domain/filter-semantics.md.

Réponse

json
{
  "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.0 en branche filtre-seul.
  • semantic_score : similarité cosinus (1 - vector_distance). 0.0 en branche filtre-seul.
  • tsvector_score : ts_rank_cd. 0.0 en branche filtre-seul.
  • Forme de records[i] : dépend de infoDensity (voir ci-dessous).

infoDensity

apply_info_density() dans src/_utils.py. Quatre niveaux :

min

json
{
  "id": "…",
  "name": "…",
  "slug": "…",
  "_relevance_score": 0.016,
  "semantic_score": 0.78,
  "tsvector_score": 0.12
}

card

json
{
  "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"
}

Approche « équivalent JSON des colonnes générées » :

json
{
  "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=search expose destination comme 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. orderBy ignoré.
  • Sans ?search= :
    • orderBy=rating(raw_data->>'commentsAverageRating')::NUMERIC
    • orderBy=departurenext_departure
    • orderBy=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 :

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 de datetime.fromisoformat échoue).
  • 500 — Infomaniak down et ?search= non vide. Pas de fallback : la branche FTS pure n'est pas tentée.
  • 200 avec records: [] — 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.

json
{
  "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).

Fichier statique servi via StaticFiles(directory=".well-known") (cf. app.py ligne 26).

Contenu actuel :

json
[{
  "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 :

python
_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=true si on veut publier la doc en interne.

CORS

python
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).

Contributors

No contributors

Changelog

No recent changes