# ============================================ # 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")