# ============================================
# Projet : API-KA
# Fichier : src/api/main.py
# Node : m3u96b
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Date : 2026-08-17
# ============================================
"""Application FastAPI d'API-KA — servie par uvicorn sur 127.0.0.1:8000,
exposée publiquement uniquement via le tunnel ngrok www.api-ka.com."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from src.api import reqstats
from src.api.middleware.logging import RequestLoggingMiddleware
from src.api.middleware.kaauth import KaAuthMiddleware
from src.api.middleware.ratelimit import RateLimitMiddleware
from src.api.routes import (
agent,
auth,
envelope,
health,
iosauth,
monitoring,
runs,
search,
services,
stats,
)
from src.config import SERVICES, get_settings, verify_node
from src.database.db import init_db
from src.utils.logger import get_logger
VERSION = "1.0.0"
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Au démarrage : vérifie le node m3u96b, initialise le schéma DB et lance
la tâche de fond qui persiste le journal léger des requêtes (/stats)."""
verify_node()
init_db()
get_logger("apika.api").info(
"API-KA démarrée",
extra={"version": VERSION, "node": get_settings().node_name},
)
flusher = asyncio.create_task(reqstats.flusher_task())
try:
yield
finally:
flusher.cancel()
with suppress(asyncio.CancelledError):
await flusher
app = FastAPI(
title="API-KA",
description=(
"Plateforme centrale de l'écosystème KA — données quotidiennes des services "
"lou-ka, immo-ka, food-ka, auto-ka et fabri-ka. Node : m3u96b. "
"Un service Groupe KA (www.groupe-ka.com)."
),
version=VERSION,
lifespan=lifespan,
)
# CORS — le widget KA Agent appelle cette API depuis les 13 sites
app.add_middleware(
CORSMiddleware,
allow_origins=['https://www.groupe-ka.com', 'https://groupe-ka.com', 'https://www.trouve-ka.com', 'https://trouve-ka.com', 'https://www.lou-ka.com', 'https://lou-ka.com', 'https://www.immo-ka.com', 'https://immo-ka.com', 'https://www.house-ka.com', 'https://house-ka.com', 'https://www.vrai-prix.com', 'https://vrai-prix.com', 'https://www.auto-ka.com', 'https://auto-ka.com', 'https://www.fabri-ka.com', 'https://fabri-ka.com', 'https://www.food-ka.com', 'https://food-ka.com', 'https://www.resto-ka.com', 'https://resto-ka.com', 'https://www.sorti-ka.com', 'https://sorti-ka.com', 'https://www.crea-ka.com', 'https://crea-ka.com', 'https://www.api-ka.com', 'https://api-ka.com', 'https://www.job-ka.com', 'https://job-ka.com'],
allow_methods=["GET", "POST"],
allow_headers=["content-type"],
)
# Authentification KA ID/jeton sur l'API produit (voir middleware/kaauth.py),
# ajoutée AVANT le rate limiting dans la pile (elle s'exécute donc après lui).
app.add_middleware(KaAuthMiddleware)
# Le rate limiting s'applique en premier sur la requête entrante ; la
# journalisation englobe tout (elle logge aussi les réponses 429).
app.add_middleware(RateLimitMiddleware)
app.add_middleware(RequestLoggingMiddleware)
# /api/v1/runs et /api/v1/search doivent être enregistrés avant les routes
# génériques /api/v1/{service} (sinon leur catch-all capturerait ces chemins).
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(runs.router)
app.include_router(monitoring.router)
app.include_router(stats.router)
app.include_router(search.router)
app.include_router(services.router)
app.include_router(agent.router)
app.include_router(iosauth.router)
WEB_DIR = Path(__file__).resolve().parent / "web"
WEB_INDEX = WEB_DIR / "index.html"
WEB_CONTACT = WEB_DIR / "contact.html"
WEB_STATS = WEB_DIR / "stats.html"
KA_DIR = WEB_DIR / "ka"
# Package ka-ui vendorisé (tokens.css, ecosystem.json, ka-shell.js) — servi
# statiquement pour le site de documentation. Voir frontend → src/api/web/ka.
if KA_DIR.is_dir():
app.mount("/ka", StaticFiles(directory=KA_DIR), name="ka")
# Documentation utilisateur (guide pas à pas + PDF) — html=True sert
# web/doc/index.html à /doc/ (et redirige /doc → /doc/).
DOC_DIR = WEB_DIR / "doc"
if DOC_DIR.is_dir():
app.mount("/doc", StaticFiles(directory=DOC_DIR, html=True), name="doc")
@app.get("/", tags=["root"])
@app.head("/", include_in_schema=False)
def root(request: Request) -> Any:
"""Statut de la plateforme + version.
Négociation de contenu : les navigateurs (Accept: text/html) reçoivent la
plateforme web de documentation avec playground ; les clients API reçoivent
le statut JSON uniforme.
"""
accept = request.headers.get("accept", "")
if "text/html" in accept and WEB_INDEX.is_file():
return FileResponse(WEB_INDEX, media_type="text/html")
return envelope(
{
"platform": "API-KA",
"status": "ok",
"version": VERSION,
"services": list(SERVICES),
"docs": "/docs",
}
)
@app.get("/contact", tags=["root"])
def contact(request: Request) -> Any:
"""Page contact Groupe KA — HTML pour les navigateurs, JSON pour les clients API.
Les coordonnées proviennent d'ecosystem.json (source de vérité ka-ui).
"""
accept = request.headers.get("accept", "")
if "text/html" in accept and WEB_CONTACT.is_file():
return FileResponse(WEB_CONTACT, media_type="text/html")
eco_path = KA_DIR / "ecosystem.json"
eco = json.loads(eco_path.read_text(encoding="utf-8")) if eco_path.is_file() else {}
return envelope(
{
"org": eco.get("org", {}).get("name", "Groupe KA"),
"hub": eco.get("hub", {}).get("url", "https://www.groupe-ka.com"),
"contacts": eco.get("contacts", []),
}
)
@app.get("/stats", tags=["root"], include_in_schema=False)
def stats_page() -> FileResponse:
"""Page /stats — tableau de bord analytique de la plateforme (HTML statique).
Les données proviennent de GET /api/stats/dashboard ; l'export PDF de
GET /api/stats/report (gabarit Groupe-KA).
"""
return FileResponse(WEB_STATS, media_type="text/html")
# --- Référencement du site de documentation (www.api-ka.com) -------------------
BASE_URL = "https://www.api-ka.com"
@app.get("/robots.txt", include_in_schema=False)
def robots_txt() -> PlainTextResponse:
return PlainTextResponse(
"User-agent: *\n"
"Allow: /\n"
"Disallow: /api/\n"
"Disallow: /docs\n"
"Disallow: /openapi.json\n"
f"\nSitemap: {BASE_URL}/sitemap.xml\n")
@app.get("/sitemap.xml", include_in_schema=False)
def sitemap_xml() -> Response:
urls = "".join(
f"{BASE_URL}{p}"
for p in ("/", "/contact", "/stats"))
return Response(
'\n'
''
+ urls + "",
media_type="application/xml",
headers={"Cache-Control": "public, max-age=3600"})
# Assets de marque Groupe KA servis à la racine du domaine (voir src/api/web/) :
# favicon SVG, icône iOS et image Open Graph 1200x630 referencée par les meta og:image.
@app.get("/favicon.svg", include_in_schema=False)
def favicon() -> FileResponse:
return FileResponse(WEB_DIR / "favicon.svg", media_type="image/svg+xml")
@app.get("/apple-touch-icon.png", include_in_schema=False)
def apple_touch_icon() -> FileResponse:
return FileResponse(WEB_DIR / "apple-touch-icon.png", media_type="image/png")
@app.get("/og.png", include_in_schema=False)
def og_image() -> FileResponse:
return FileResponse(WEB_DIR / "og.png", media_type="image/png")
@app.get("/ka-agent.js", include_in_schema=False)
def ka_agent_js() -> FileResponse:
"""Widget KA Agent — bulle de chat IA commune a l ecosysteme Groupe KA."""
return FileResponse(WEB_DIR / "ka-agent.js", media_type="application/javascript")