"""OpenAPI 3.0 + Prometheus-ish metrics exporter. Both endpoints are **unauthenticated** by design so they can be scraped by third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing to solve a JWT exchange first. The OpenAPI spec is generated by introspecting every class decorated with :func:`odoo.http.route` and therefore stays in sync with the running server automatically. Scope: the generator emits a minimal but valid OpenAPI document — routes, HTTP methods, path params, tag derived from the route prefix, and a shared ``BearerAuth`` security scheme. Request / response schemas are left open (``{"type": "object"}``) since we don't have pydantic models yet; a future iteration can enrich individual routes via ``route_decorated_method.openapi`` annotations. """ from __future__ import annotations import logging import re import time from collections import Counter, defaultdict from odoo import http from odoo.http import Controller, Response, request _logger = logging.getLogger(__name__) _metrics_lock = None _metrics: dict = { "started_at": time.time(), "routes": Counter(), # key -> hit count "statuses": Counter(), # status bucket -> hit count "latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128) } _LATENCY_SAMPLE_CAP = 128 _PATH_PARAM_RE = re.compile(r"<(?:(?P\w+):)?(?P\w+)>") def record_request(route: str, status: int, duration_ms: int) -> None: """Lightweight in-process request counter used by the metrics endpoint. Intentionally thread-unsafe for minimum overhead; worst case is a slightly off counter on concurrent writes which is fine for ops dashboards. Switch to a proper Prometheus client (``prometheus_client``) in P2.3 when needed. """ try: _metrics["routes"][route] += 1 bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx" _metrics["statuses"][bucket] += 1 samples = _metrics["latencies_ms"][route] samples.append(int(duration_ms)) if len(samples) > _LATENCY_SAMPLE_CAP: del samples[0:len(samples) - _LATENCY_SAMPLE_CAP] except Exception: pass def _convert_odoo_path(path: str) -> str: """Translate Odoo's ```` placeholder syntax to OpenAPI ``{foo}``.""" def repl(m: re.Match) -> str: return "{" + m.group("name") + "}" return _PATH_PARAM_RE.sub(repl, path) def _path_parameters(path: str) -> list[dict]: params = [] for m in _PATH_PARAM_RE.finditer(path): conv = (m.group("conv") or "string").lower() if conv in ("int", "integer"): schema = {"type": "integer"} elif conv in ("float", "number"): schema = {"type": "number"} else: schema = {"type": "string"} params.append({ "name": m.group("name"), "in": "path", "required": True, "schema": schema, }) return params def _iter_routes(): """Yield ``(route_str, method_name, tag, class_name, doc)`` for every ``@http.route`` decorated callable currently registered. Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the dispatcher actually routes to — including extension classes. """ seen = set() try: routing_map = request.env["ir.http"].routing_map() except Exception: _logger.exception("could not fetch routing_map") return try: rules = list(routing_map.iter_rules()) except Exception: _logger.exception("routing_map has no iter_rules") return for rule in rules: url = rule.rule if not isinstance(url, str) or not url.startswith("/api/"): continue endpoint = rule.endpoint # ``endpoint`` is a wrapped callable; the real controller method is # stashed on ``endpoint.func`` by Odoo. fn = getattr(endpoint, "func", endpoint) routing = getattr(fn, "routing", {}) or {} methods = routing.get("methods") or list(rule.methods or {"GET"}) methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}] if not methods: methods = ["GET"] cls_name = "" try: qual = getattr(fn, "__qualname__", fn.__name__) cls_name = qual.split(".")[0] if "." in qual else "" except Exception: cls_name = "" module = getattr(fn, "__module__", "") or "" tag_base = module.split(".")[-2] if "." in module else module or "api" doc = "" try: doc = (fn.__doc__ or "").strip() except Exception: pass for m in methods: key = (url, m.upper(), cls_name, fn.__name__) if key in seen: continue seen.add(key) yield url, m.upper(), tag_base, cls_name, doc def _build_openapi_spec() -> dict: base_url = ( request.env["ir.config_parameter"].sudo().get_param("web.base.url") or "http://localhost:8069" ) paths: dict[str, dict] = {} tags: dict[str, str] = {} for route, method, tag_base, cls_name, doc in _iter_routes(): openapi_path = _convert_odoo_path(route) path_entry = paths.setdefault(openapi_path, {}) # Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``). segs = [s for s in route.split("/") if s and s != "api"] tag = segs[0] if segs else tag_base tags.setdefault(tag, f"Endpoints under /api/{tag}") summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}" op: dict = { "summary": summary, "operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}", "tags": [tag], "security": [{"BearerAuth": []}], "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object"}}}}, "401": {"description": "Authentication required"}, "400": {"description": "Bad request"}, "500": {"description": "Server error"}, }, } params = _path_parameters(route) if params: op["parameters"] = params if method in ("POST", "PUT", "PATCH"): op["requestBody"] = { "required": False, "content": {"application/json": {"schema": {"type": "object"}}}, } path_entry[method.lower()] = op return { "openapi": "3.0.3", "info": { "title": "EnCoach Platform API", "version": "19.0.1.0", "description": ( "Auto-generated from ``@http.route`` decorators. " "Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the " "roadmap to enrich request/response shapes." ), }, "servers": [{"url": base_url}], "tags": [{"name": name, "description": desc} for name, desc in sorted(tags.items())], "components": { "securitySchemes": { "BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT", }, }, }, "paths": dict(sorted(paths.items())), } class EncoachOpenAPIController(Controller): @http.route("/api/openapi.json", type="http", auth="none", methods=["GET"], csrf=False) def openapi(self, **_kw): """Return a freshly generated OpenAPI 3.0 spec for the running server.""" try: spec = _build_openapi_spec() return request.make_json_response(spec) except Exception: _logger.exception("openapi generation failed") return request.make_json_response( {"error": "could not generate openapi spec"}, status=500, ) @http.route("/api/metrics", type="http", auth="none", methods=["GET"], csrf=False) def metrics(self, **_kw): """Return in-process request counters in a Prometheus-compatible text format. This is a minimalist shim, not a full Prometheus client. It exposes: - ``encoach_requests_total{route, method, status_bucket}`` — request count - ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based) - ``encoach_uptime_seconds`` — process uptime """ lines = [ "# HELP encoach_uptime_seconds Seconds since this worker booted", "# TYPE encoach_uptime_seconds gauge", f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}", "", "# HELP encoach_requests_total Total number of /api/* requests handled", "# TYPE encoach_requests_total counter", ] for route, count in _metrics["routes"].most_common(): safe = route.replace('"', '\\"') lines.append(f'encoach_requests_total{{route="{safe}"}} {count}') lines.append("") lines.append("# HELP encoach_responses_total Count of responses per status bucket") lines.append("# TYPE encoach_responses_total counter") for bucket, count in _metrics["statuses"].items(): lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}') lines.append("") lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route") lines.append("# TYPE encoach_request_latency_ms_p95 gauge") for route, samples in _metrics["latencies_ms"].items(): if not samples: continue sorted_samples = sorted(samples) p95 = sorted_samples[min(len(sorted_samples) - 1, int(0.95 * len(sorted_samples)))] safe = route.replace('"', '\\"') lines.append( f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}' ) body = "\n".join(lines) + "\n" return Response(body, status=200, content_type="text/plain; version=0.0.4")