feat(api): exposer le blueprint HTTP sécurisé Home Assistant (étape 4)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
This commit is contained in:
166
app/api.py
Normal file
166
app/api.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""API JSON pour les événements de présence Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, current_app, jsonify, request
|
||||
|
||||
from app import db
|
||||
from app.business.presence_service import (
|
||||
ArrivalAlreadyOpenError,
|
||||
DepartureWithoutArrivalError,
|
||||
IdempotencyConflictError,
|
||||
InvalidPresenceEventError,
|
||||
record_presence_event,
|
||||
)
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||
|
||||
_MAX_BODY_BYTES = 4096
|
||||
_RATE_LIMIT = 30
|
||||
_RATE_WINDOW_SECONDS = 60.0
|
||||
_rate_lock = Lock()
|
||||
_rate_requests: defaultdict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def reset_rate_limiter() -> None:
|
||||
"""Réinitialise la protection mémoire, notamment pour les tests."""
|
||||
with _rate_lock:
|
||||
_rate_requests.clear()
|
||||
|
||||
|
||||
def _response(payload: dict[str, Any], status: int) -> Response:
|
||||
response = jsonify(payload)
|
||||
response.status_code = status
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _error(status: int, message: str) -> Response:
|
||||
return _response({"error": message}, status)
|
||||
|
||||
|
||||
def _authenticated() -> tuple[str | None, Response | None]:
|
||||
configured_token = os.environ.get("WORKLOG_API_TOKEN")
|
||||
if not configured_token:
|
||||
return None, _error(503, "API indisponible")
|
||||
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
scheme, separator, supplied_token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not separator or not supplied_token:
|
||||
return None, _error(401, "Authentification requise")
|
||||
if not hmac.compare_digest(supplied_token, configured_token):
|
||||
return None, _error(401, "Authentification requise")
|
||||
return supplied_token, None
|
||||
|
||||
|
||||
def _rate_allowed(token: str) -> bool:
|
||||
# Cette limite est par processus ; HAProxy ou une solution partagée est nécessaire
|
||||
# pour garantir la limite à l'échelle de plusieurs workers.
|
||||
now = monotonic()
|
||||
with _rate_lock:
|
||||
requests_for_token = _rate_requests[token]
|
||||
requests_for_token[:] = [
|
||||
timestamp for timestamp in requests_for_token if now - timestamp < _RATE_WINDOW_SECONDS
|
||||
]
|
||||
if len(requests_for_token) >= _RATE_LIMIT:
|
||||
return False
|
||||
requests_for_token.append(now)
|
||||
return True
|
||||
|
||||
|
||||
@bp.route("/workplace-presence", methods=["POST"])
|
||||
def workplace_presence() -> Response:
|
||||
"""Réceptionne un événement d'arrivée ou de départ authentifié."""
|
||||
if current_app.config.get("HOME_ASSISTANT") is None:
|
||||
return _error(404, "API indisponible")
|
||||
|
||||
token, authentication_error = _authenticated()
|
||||
if authentication_error is not None:
|
||||
return authentication_error
|
||||
assert token is not None
|
||||
if not _rate_allowed(token):
|
||||
return _error(429, "Trop de requêtes")
|
||||
|
||||
if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
|
||||
return _error(413, "Requête trop volumineuse")
|
||||
raw_body = request.get_data(cache=True)
|
||||
if len(raw_body) > _MAX_BODY_BYTES:
|
||||
return _error(413, "Requête trop volumineuse")
|
||||
if request.mimetype != "application/json":
|
||||
return _error(415, "Content-Type invalide")
|
||||
|
||||
try:
|
||||
payload = request.get_json(silent=False)
|
||||
except Exception:
|
||||
return _error(400, "JSON invalide")
|
||||
if not isinstance(payload, dict):
|
||||
return _error(422, "Schéma invalide")
|
||||
|
||||
allowed_keys = {"event", "occurred_at", "idempotency_key"}
|
||||
if (
|
||||
not set(payload).issubset(allowed_keys)
|
||||
or "event" not in payload
|
||||
or "occurred_at" not in payload
|
||||
):
|
||||
return _error(422, "Schéma invalide")
|
||||
event = payload["event"]
|
||||
occurred_at = payload["occurred_at"]
|
||||
body_key = payload.get("idempotency_key")
|
||||
header_key = request.headers.get("X-Idempotency-Key")
|
||||
if body_key is not None and (not isinstance(body_key, str) or not body_key.strip()):
|
||||
return _error(422, "Schéma invalide")
|
||||
if header_key is not None and not header_key.strip():
|
||||
return _error(422, "Schéma invalide")
|
||||
if body_key is not None and header_key is not None and body_key != header_key:
|
||||
return _error(422, "Schéma invalide")
|
||||
idempotency_key = body_key if body_key is not None else header_key
|
||||
if not all(
|
||||
isinstance(value, str) and value.strip() for value in (event, occurred_at, idempotency_key)
|
||||
):
|
||||
return _error(422, "Schéma invalide")
|
||||
|
||||
try:
|
||||
result = record_presence_event(
|
||||
db.session,
|
||||
current_app.config["HOME_ASSISTANT"],
|
||||
event,
|
||||
occurred_at,
|
||||
idempotency_key,
|
||||
)
|
||||
db.session.commit()
|
||||
except InvalidPresenceEventError:
|
||||
db.session.rollback()
|
||||
return _error(422, "Événement invalide")
|
||||
except (ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError):
|
||||
db.session.rollback()
|
||||
return _error(409, "Conflit métier")
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return _error(500, "Erreur interne")
|
||||
|
||||
status = "replayed" if result.replayed else "created"
|
||||
http_status = 200 if result.replayed or event == "departure" else 201
|
||||
return _response(
|
||||
{
|
||||
"status": status,
|
||||
"event_id": result.event_id,
|
||||
"entry_id": result.entry_id,
|
||||
"time_slot_id": result.time_slot_id,
|
||||
"event_type": event,
|
||||
},
|
||||
http_status,
|
||||
)
|
||||
|
||||
|
||||
@bp.app_errorhandler(405)
|
||||
def method_not_allowed(error):
|
||||
if request.path.startswith("/api/v1/"):
|
||||
return _error(405, "Méthode non autorisée")
|
||||
return error
|
||||
Reference in New Issue
Block a user