diff --git a/.env.sample b/.env.sample index a207582d..5b8ef872 100644 --- a/.env.sample +++ b/.env.sample @@ -6,6 +6,10 @@ FRONTEND_HOST=http://localhost:5173 # Environment: local, staging, production ENVIRONMENT=local +# Logging: json is what the cluster expects, text is the readable form for local dev +LOG_LEVEL=INFO +LOG_FORMAT=text + PROJECT_NAME='Rapid Evaluation Framework' # Backend diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4fe08874..4eb2d8d4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "pydantic-settings<3.0.0,>=2.13.1", "sentry-sdk[fastapi]>=2.0.0", "climate-ref[aft-providers,postgres]>=0.18.0,<0.19", - "loguru", + "prometheus-fastapi-instrumentator>=7.0.0", "pyyaml>=6.0", "fastapi-sqlalchemy-monitor>=1.1.3", ] diff --git a/backend/src/ref_backend/analytics.py b/backend/src/ref_backend/analytics.py index fc63e054..06d3d264 100644 --- a/backend/src/ref_backend/analytics.py +++ b/backend/src/ref_backend/analytics.py @@ -7,9 +7,12 @@ so both deploy targets need to stay in step. """ +import logging + import httpx from fastapi import APIRouter, Request, Response -from loguru import logger + +logger = logging.getLogger(__name__) PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/script.file-downloads.outbound-links.js" PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event" diff --git a/backend/src/ref_backend/api/routes/aft.py b/backend/src/ref_backend/api/routes/aft.py index 054f5953..72ad73e7 100644 --- a/backend/src/ref_backend/api/routes/aft.py +++ b/backend/src/ref_backend/api/routes/aft.py @@ -1,9 +1,12 @@ +import logging + from fastapi import APIRouter, HTTPException -from loguru import logger from ref_backend.core.aft import get_aft_diagnostic_by_id, get_aft_diagnostics_index from ref_backend.models import AFTDiagnosticDetail, AFTDiagnosticSummary +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/cmip7-aft-diagnostics", tags=["CMIP7 Assessment Fast Track (AFT)"]) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 891e0ba3..772e01a4 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -1,3 +1,4 @@ +import logging import mimetypes import os import tarfile @@ -7,7 +8,6 @@ from typing import Literal from fastapi import APIRouter, HTTPException, Query, Request -from loguru import logger from sqlalchemy import select from sqlalchemy.orm import Session from starlette.responses import StreamingResponse @@ -39,6 +39,8 @@ ) from ref_backend.models.executions import EXECUTION_GROUP_LOAD_OPTIONS +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/executions", tags=["executions"]) diff --git a/backend/src/ref_backend/api/routes/utils.py b/backend/src/ref_backend/api/routes/utils.py index d055cef2..6e27e2ad 100644 --- a/backend/src/ref_backend/api/routes/utils.py +++ b/backend/src/ref_backend/api/routes/utils.py @@ -10,11 +10,6 @@ router = APIRouter(prefix="/utils", tags=["utils"]) -@router.get("/health-check/") -async def health_check() -> bool: - return True - - @router.get("/about") async def about(session: SessionDep, settings: SettingsDep) -> About: """ diff --git a/backend/src/ref_backend/builder.py b/backend/src/ref_backend/builder.py index 53fbc0e9..223ff09b 100644 --- a/backend/src/ref_backend/builder.py +++ b/backend/src/ref_backend/builder.py @@ -1,11 +1,13 @@ +import logging +from collections.abc import Callable from dataclasses import asdict import sentry_sdk +import sqlalchemy from fastapi import FastAPI from fastapi.routing import APIRoute from fastapi_sqlalchemy_monitor import AlchemyStatistics, SQLAlchemyMonitor from fastapi_sqlalchemy_monitor.action import Action, ConditionalAction, WarnMaxTotalInvocation -from loguru import logger from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware from starlette.responses import Response @@ -17,6 +19,12 @@ from ref_backend.analytics import router as analytics_router from ref_backend.api.main import api_router from ref_backend.core.config import Settings +from ref_backend.deploy import router as deploy_router +from ref_backend.health import router as health_router +from ref_backend.metrics import instrument_app +from ref_backend.middleware import WideEventMiddleware + +logger = logging.getLogger(__name__) description = """ API for querying the results from the Climate Rapid Evaluation Framework (Climate REF). @@ -33,6 +41,9 @@ def custom_generate_unique_id(route: APIRoute) -> str: + # Untagged routes such as /metrics are not part of the client, so the bare name is enough. + if not route.tags: + return route.name return f"{route.tags[0]}-{route.name}" @@ -63,6 +74,19 @@ def handle(self, statistics: AlchemyStatistics) -> None: logger.info(asdict(statistics)) +def _database_readiness_check(database: Database) -> Callable[[], bool]: + """ + Build a readiness check that proves the configured database still answers + """ + + def database_reachable() -> bool: + with database._engine.connect() as connection: + connection.execute(sqlalchemy.text("SELECT 1")) + return True + + return database_reachable + + class SPAStaticFiles(StaticFiles): """ Static file handler with SPA fallback. @@ -127,12 +151,22 @@ def build_app(settings: Settings, ref_config: Config, database: Database) -> Fas allow_credentials=False, allow_methods=["GET"], allow_headers=["*"], + expose_headers=["x-request-id", "x-process-time"], ) + app.add_middleware(WideEventMiddleware) + + app.state.readiness_checks = [_database_readiness_check(database)] + app.include_router(api_router, prefix=settings.API_V1_STR) # Mounted above the static files, which only answer GET and HEAD. app.include_router(analytics_router) + app.include_router(health_router) + app.include_router(deploy_router) + + # Registers /metrics, which has to be in place before the catch-all SPA mount below. + instrument_app(app) if settings.STATIC_DIR: logger.info(f"Serving static files from {settings.STATIC_DIR}") diff --git a/backend/src/ref_backend/core/config.py b/backend/src/ref_backend/core/config.py index bb5e0f4f..b2208d7f 100644 --- a/backend/src/ref_backend/core/config.py +++ b/backend/src/ref_backend/core/config.py @@ -32,6 +32,13 @@ class Settings(BaseSettings): BACKEND_HOST: str = "http://localhost:8000" ENVIRONMENT: Literal["local", "staging", "production"] = "local" LOG_LEVEL: str = "INFO" + LOG_FORMAT: Literal["json", "text"] = "json" + """ + Log line format. + + ``json`` emits one parseable event per record. ``text`` is the human-readable form for local dev. + """ + DIAGNOSTIC_PROVIDERS: list[str] | None = None """ Limit the diagnostics to only query the providers defined in this list. diff --git a/backend/src/ref_backend/core/diagnostic_metadata.py b/backend/src/ref_backend/core/diagnostic_metadata.py index d197bc88..09863d3f 100644 --- a/backend/src/ref_backend/core/diagnostic_metadata.py +++ b/backend/src/ref_backend/core/diagnostic_metadata.py @@ -8,15 +8,17 @@ """ import functools +import logging from collections.abc import Mapping from pathlib import Path from types import MappingProxyType from typing import Literal import yaml -from loguru import logger from pydantic import BaseModel, Field +logger = logging.getLogger(__name__) + class ReferenceDatasetLink(BaseModel): """ diff --git a/backend/src/ref_backend/core/ref.py b/backend/src/ref_backend/core/ref.py index 66709165..e20c22f1 100644 --- a/backend/src/ref_backend/core/ref.py +++ b/backend/src/ref_backend/core/ref.py @@ -1,12 +1,13 @@ +import logging from pathlib import Path -from loguru import logger - from climate_ref.config import Config from climate_ref.database import Database, MigrationState from climate_ref.provider_registry import ProviderRegistry from ref_backend.core.config import Settings +logger = logging.getLogger(__name__) + def get_ref_config(settings: Settings) -> Config: """ diff --git a/backend/src/ref_backend/deploy.py b/backend/src/ref_backend/deploy.py new file mode 100644 index 00000000..ccbbb22c --- /dev/null +++ b/backend/src/ref_backend/deploy.py @@ -0,0 +1,38 @@ +""" +Route reporting what is actually deployed + +Not included in the schema: it is an operator diagnostic, not part of the public API. +The build stamps here are the same values the wide events carry. +""" + +import os +from importlib.metadata import version + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/deploy", include_in_schema=False) + + +class DeployInfo(BaseModel): + """ + Information about the running deployment + """ + + version: str + git_commit: str | None + image_tag: str | None + build_time: str | None + + +@router.get("/info") +def deploy_info() -> DeployInfo: + """ + Return the version and build stamps baked into the image + """ + return DeployInfo( + version=version("ref-backend"), + git_commit=os.environ.get("GIT_COMMIT") or None, + image_tag=os.environ.get("IMAGE_TAG") or None, + build_time=os.environ.get("BUILD_TIME") or None, + ) diff --git a/backend/src/ref_backend/health.py b/backend/src/ref_backend/health.py new file mode 100644 index 00000000..c78bf522 --- /dev/null +++ b/backend/src/ref_backend/health.py @@ -0,0 +1,52 @@ +""" +Liveness and readiness probes + +Mounted at the root rather than under the versioned API, so an orchestrator can probe the +process without knowing anything about the API surface. +""" + +import inspect +import logging + +from fastapi import APIRouter, HTTPException, Request + +logger = logging.getLogger(__name__) + +router = APIRouter(include_in_schema=False) + + +@router.get("/livez") +def liveness_probe() -> dict[str, str]: + """ + Report process liveness without touching any dependency + """ + return {"status": "alive"} + + +@router.get("/readyz") +async def readiness_probe(request: Request) -> dict[str, str]: + """ + Report readiness by running the checks registered on ``app.state.readiness_checks`` + + Each check is a callable that returns a falsy value or raises to signal it is not ready. + A check may be async, in which case what it returns is awaited. + An empty (or unset) check list means the service is always ready. + """ + checks = getattr(request.app.state, "readiness_checks", []) + failures: dict[str, str] = {} + for check in checks: + name = getattr(check, "__name__", repr(check)) + try: + ok = check() + if inspect.isawaitable(ok): + ok = await ok + except Exception as exc: + logger.warning(f"Readiness check {name} failed", exc_info=True) + failures[name] = str(exc) + continue + if not ok: + failures[name] = "check returned a falsy result" + + if failures: + raise HTTPException(status_code=503, detail=failures) + return {"status": "ready"} diff --git a/backend/src/ref_backend/log.py b/backend/src/ref_backend/log.py deleted file mode 100644 index b0593af0..00000000 --- a/backend/src/ref_backend/log.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging - -from loguru import logger - - -class InterceptHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - # Get corresponding Loguru level - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno # type: ignore[assignment] - - # Find caller to get correct stack depth - frame, depth = logging.currentframe(), 2 - while frame.f_back and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def setup_logging(log_level: str) -> None: - # Remove existing handlers - for handler in logging.root.handlers[:]: - logging.root.removeHandler(handler) - - # Intercept standard logging - logging.basicConfig(handlers=[InterceptHandler()], level=log_level) - - # List of loggers to intercept - loggers = ( - "uvicorn", - "uvicorn.access", - "uvicorn.error", - "fastapi", - "asyncio", - "starlette", - ) - - for logger_name in loggers: - logging_logger = logging.getLogger(logger_name) - logging_logger.handlers = [] - logging_logger.propagate = True diff --git a/backend/src/ref_backend/logging_config.py b/backend/src/ref_backend/logging_config.py new file mode 100644 index 00000000..0a4e553b --- /dev/null +++ b/backend/src/ref_backend/logging_config.py @@ -0,0 +1,197 @@ +""" +Structured JSON logging configuration + +Installs a single JSON formatter on the root logger so every log line is one parseable event per record, +and silences uvicorn's plaintext access log in favour of the wide-event middleware. + +For local development, set ``LOG_FORMAT=text`` to get a human-readable line format +that still includes all the structured fields in an appended ``key=value`` format. +""" + +import json +import logging +import os +import sys +import time +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +from loguru import logger as loguru_logger + +_ENV_CONTEXT: dict[str, Any] = {} + +_RESERVED_LOG_RECORD_KEYS = frozenset( + { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "taskName", + "thread", + "threadName", + } +) + + +def build_env_context() -> dict[str, Any]: + """ + Return the static fields baked into every log line + """ + try: + app_version = version("ref-backend") + except PackageNotFoundError: + app_version = None + + fields = { + "service": "ref-backend", + "version": app_version, + "commit": os.environ.get("GIT_COMMIT") or os.environ.get("IMAGE_TAG") or None, + "env": os.environ.get("ENVIRONMENT", "local"), + "instance_id": os.environ.get("HOSTNAME"), + } + return {k: v for k, v in fields.items() if v is not None} + + +class JsonFormatter(logging.Formatter): + """ + Single-line JSON formatter that flattens ``extra`` and merges env context + """ + + def format(self, record: logging.LogRecord) -> str: + """ + Render the record as a single-line JSON object + """ + payload: dict[str, Any] = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) + + f".{int(record.msecs):03d}Z", + "level": record.levelname.lower(), + "logger": record.name, + "message": record.getMessage(), + } + payload.update(_ENV_CONTEXT) + + for key, value in record.__dict__.items(): + if key in _RESERVED_LOG_RECORD_KEYS or key.startswith("_"): + continue + payload[key] = value + + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + + return json.dumps(payload, default=str, separators=(",", ":")) + + +class TextFormatter(logging.Formatter): + """ + Human-readable formatter for local dev + + Renders one line per record with timestamp, level, logger, message, + then any ``extra`` fields appended as ``key=value`` pairs. + Wide-event records therefore stay grep-friendly without forcing operators to read JSON. + """ + + _DEFAULT_FMT = "%(asctime)s %(levelname)-5s %(name)s %(message)s" + + def __init__(self) -> None: + super().__init__(fmt=self._DEFAULT_FMT, datefmt="%Y-%m-%dT%H:%M:%S") + + def format(self, record: logging.LogRecord) -> str: + """ + Render the record as `` k=v ...`` + """ + base = super().format(record) + extras = [] + for key, value in record.__dict__.items(): + if key in _RESERVED_LOG_RECORD_KEYS or key.startswith("_"): + continue + extras.append(f"{key}={value!r}") + if extras: + base = f"{base} | {' '.join(extras)}" + if record.exc_info and not record.exc_text: + base = f"{base}\n{self.formatException(record.exc_info)}" + return base + + +def _resolve_format(explicit: str | None) -> str: + """ + Pick the formatter name: explicit arg > LOG_FORMAT env > default ``json`` + """ + raw = (explicit or os.environ.get("LOG_FORMAT") or "json").strip().lower() + if raw in {"json", "text"}: + return raw + return "json" + + +def _loguru_sink(message: Any) -> None: + """ + Re-emit a loguru record through the standard library, so it picks up our formatter + """ + record = message.record + logging.getLogger(record["name"] or "loguru").log( + record["level"].no, + record["message"], + exc_info=record["exception"], + ) + + +def _bridge_loguru(level: str | int) -> None: + """ + Route loguru through the standard library + + climate-ref and its providers log via loguru, so without this their records bypass + the JSON formatter and land on stderr unstructured. + """ + loguru_logger.remove() + loguru_logger.add(_loguru_sink, level=level, format="{message}") + + +def configure_logging(level: str | int = "INFO", fmt: str | None = None) -> None: + """ + Install structured logging and silence uvicorn's plaintext access log + + The handler format is JSON by default and switches to a human-readable + line format when ``LOG_FORMAT=text`` (or ``fmt="text"``) is set. Idempotent: + safe to call again after uvicorn re-applies its own dictConfig on startup. + """ + _ENV_CONTEXT.clear() + _ENV_CONTEXT.update(build_env_context()) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(TextFormatter() if _resolve_format(fmt) == "text" else JsonFormatter()) + + root = logging.getLogger() + if "PYTEST_CURRENT_TEST" in os.environ: + # In tests, keep pytest's capturing handlers and add ours alongside, once. + already_attached = any(isinstance(h.formatter, JsonFormatter | TextFormatter) for h in root.handlers) + if not already_attached: + root.addHandler(handler) + else: + root.handlers = [handler] + root.setLevel(level) + + _bridge_loguru(level) + + access = logging.getLogger("uvicorn.access") + access.handlers = [] + access.propagate = False + + for name in ("uvicorn", "uvicorn.error", "fastapi"): + log = logging.getLogger(name) + log.handlers = [] + log.propagate = True diff --git a/backend/src/ref_backend/main.py b/backend/src/ref_backend/main.py index c5ba817d..1c2c21df 100644 --- a/backend/src/ref_backend/main.py +++ b/backend/src/ref_backend/main.py @@ -2,26 +2,34 @@ Main entry point for the FastAPI application """ +import logging + import dotenv from fastapi import HTTPException, Request, Response from fastapi.exception_handlers import ( http_exception_handler as fasthttp_exception_handler, ) -from loguru import logger from climate_ref.config import Config as RefConfig from climate_ref.provider_registry import ProviderRegistry from ref_backend.api import deps from ref_backend.core.config import get_settings -from ref_backend.log import setup_logging +from ref_backend.logging_config import configure_logging from ref_backend.testing import test_ref_config, test_settings +logger = logging.getLogger(__name__) + # Load environment variables from a .env file, if it exists dotenv.load_dotenv(override=True) # Load the settings early, to avoid climate-ref setting the `REF_CONFIGURATION` environment variable settings = get_settings() +# Configure logging before anything else imports climate-ref, so startup logs are structured too. +# uvicorn applies its own dictConfig after importing this module, which re-enables its access log, +# so configure_logging runs again below to undo that. +configure_logging(settings.LOG_LEVEL, settings.LOG_FORMAT) + from ref_backend.builder import build_app # noqa: E402 from ref_backend.core.ref import get_provider_registry, get_ref_config # noqa: E402 @@ -30,7 +38,7 @@ database = deps._get_database_dependency(settings, ref_config) provider_registry = get_provider_registry(ref_config, read_only=settings.REF_READ_ONLY_DATABASE) -setup_logging(settings.LOG_LEVEL) +configure_logging(settings.LOG_LEVEL, settings.LOG_FORMAT) app = build_app(settings, ref_config, database) diff --git a/backend/src/ref_backend/metrics.py b/backend/src/ref_backend/metrics.py new file mode 100644 index 00000000..dc07853c --- /dev/null +++ b/backend/src/ref_backend/metrics.py @@ -0,0 +1,18 @@ +""" +Prometheus metrics via prometheus-fastapi-instrumentator +""" + +import fastapi +from prometheus_fastapi_instrumentator import Instrumentator + + +def instrument_app(app: fastapi.FastAPI) -> None: + """ + Instrument the app and expose it at ``/metrics`` + + Registers the default request-count and latency histograms + (``http_requests_total``, ``http_request_duration_seconds``). + """ + Instrumentator(excluded_handlers=["/livez", "/readyz", "/metrics"]).instrument(app).expose( + app, include_in_schema=False + ) diff --git a/backend/src/ref_backend/middleware.py b/backend/src/ref_backend/middleware.py new file mode 100644 index 00000000..c1a856d4 --- /dev/null +++ b/backend/src/ref_backend/middleware.py @@ -0,0 +1,133 @@ +""" +Wide-event HTTP middleware + +Emits one structured access log per request (canonical log line) and stamps +correlation headers on the response. Replaces uvicorn's plaintext access log. +""" + +import http +import logging +import time +import uuid +from typing import Any + +import fastapi +import sentry_sdk +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +logger = logging.getLogger("access") + +# Probe and metrics hits are logged at debug so they do not drown out real traffic. +_PROBE_PATHS = frozenset({"/livez", "/readyz", "/metrics"}) + + +def _extract_sentry_trace_id() -> str | None: + """ + Best-effort lookup of the current Sentry trace_id for cross-tool correlation + """ + span = sentry_sdk.get_current_span() + if span is not None: + return getattr(span, "trace_id", None) + scope = sentry_sdk.get_current_scope() + ctx = getattr(scope, "_propagation_context", None) or getattr(scope, "propagation_context", None) + return getattr(ctx, "trace_id", None) if ctx else None + + +def _resolve_request_id(request: fastapi.Request) -> str: + """ + Reuse an upstream correlation id if present, otherwise mint one + """ + upstream = request.headers.get("x-request-id") or request.headers.get("x-amzn-trace-id") + return upstream or uuid.uuid4().hex + + +def _client_ip(request: fastapi.Request) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else None + + +def _correlation_headers(request_id: str, duration_s: float) -> list[tuple[bytes, bytes]]: + """ + Build the observability and correlation headers stamped on every response + """ + return [ + (b"x-request-id", request_id.encode()), + (b"x-process-time", f"{duration_s:.6f}".encode()), + ] + + +class WideEventMiddleware: + """ + Emit one wide event per HTTP request and stamp correlation headers + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + ASGI entry-point: wraps the downstream app to time and log the request + """ + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = fastapi.Request(scope, receive=receive) + request_id = _resolve_request_id(request) + request.state.request_id = request_id + sentry_sdk.set_tag("request_id", request_id) + + start = time.perf_counter() + status_code: int | None = None + response_bytes = 0 + + async def send_wrapper(message: Message) -> None: + nonlocal status_code, response_bytes + if message["type"] == "http.response.start": + status_code = message["status"] + duration_s = time.perf_counter() - start + headers = list(message.get("headers", [])) + headers.extend(_correlation_headers(request_id, duration_s)) + message["headers"] = headers + elif message["type"] == "http.response.body": + response_bytes += len(message.get("body", b"")) + await send(message) + + error_type: str | None = None + try: + await self.app(scope, receive, send_wrapper) + except Exception as exc: + error_type = type(exc).__name__ + if status_code is None: + status_code = 500 + raise + finally: + duration_ms = round((time.perf_counter() - start) * 1000, 2) + event: dict[str, Any] = { + "event": "http_request", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "query": dict(request.query_params), + "status": status_code, + "duration_ms": duration_ms, + "response_bytes": response_bytes, + "client_ip": _client_ip(request), + "user_agent": request.headers.get("user-agent"), + "referer": request.headers.get("referer"), + "sentry_trace_id": _extract_sentry_trace_id(), + } + + if error_type: + event["error_type"] = error_type + logger.error("http_request", extra=event) + elif ( + request.url.path in _PROBE_PATHS + and status_code is not None + and status_code < http.HTTPStatus.BAD_REQUEST + ): + logger.debug("http_request", extra=event) + else: + logger.info("http_request", extra=event) diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index 10125269..51ee1496 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -1,9 +1,9 @@ """Diagnostic summaries, including the YAML metadata overrides.""" +import logging from collections.abc import Mapping from typing import TYPE_CHECKING -from loguru import logger from pydantic import BaseModel from sqlalchemy import func @@ -17,6 +17,8 @@ from ref_backend.models.aft import AFTDiagnosticDetail from ref_backend.models.common import GroupBy, ProviderSummary +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from ref_backend.api.deps import AppContext diff --git a/backend/tests/test_api/test_routes/test_utils.py b/backend/tests/test_api/test_routes/test_utils.py index 120ac8d1..d88a9ec2 100644 --- a/backend/tests/test_api/test_routes/test_utils.py +++ b/backend/tests/test_api/test_routes/test_utils.py @@ -6,18 +6,6 @@ from ref_backend.testing import test_ref_config, test_settings -def test_health_check(client: TestClient, settings) -> None: - r = client.get( - f"{settings.API_V1_STR}/utils/health-check", - ) - - assert r.status_code == 200 - - data = r.json() - - assert data is True - - def test_about(client: TestClient, settings) -> None: r = client.get( f"{settings.API_V1_STR}/utils/about", diff --git a/backend/tests/test_deploy.py b/backend/tests/test_deploy.py new file mode 100644 index 00000000..79a3e0ed --- /dev/null +++ b/backend/tests/test_deploy.py @@ -0,0 +1,24 @@ +""" +Tests for the deployment information route +""" + +from fastapi.testclient import TestClient + + +def test_deploy_info(client: TestClient) -> None: + response = client.get("/deploy/info") + assert response.status_code == 200 + body = response.json() + assert set(body) == {"version", "git_commit", "image_tag", "build_time"} + + +def test_deploy_info_reports_build_stamps(monkeypatch, client: TestClient) -> None: + monkeypatch.setenv("GIT_COMMIT", "abc123") + monkeypatch.setenv("IMAGE_TAG", "v1.2.3") + monkeypatch.setenv("BUILD_TIME", "2026-08-21T00:00:00Z") + + response = client.get("/deploy/info") + body = response.json() + assert body["git_commit"] == "abc123" + assert body["image_tag"] == "v1.2.3" + assert body["build_time"] == "2026-08-21T00:00:00Z" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 00000000..bcd14be8 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,57 @@ +""" +Tests for the liveness and readiness probes +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def restore_readiness_checks(app: FastAPI): + """ + The app fixture is session scoped, so a test that swaps the checks has to put them back + """ + original = app.state.readiness_checks + yield + app.state.readiness_checks = original + + +def test_livez(client: TestClient) -> None: + response = client.get("/livez") + assert response.status_code == 200 + assert response.json() == {"status": "alive"} + + +def test_readyz_reports_the_database_as_reachable(client: TestClient) -> None: + response = client.get("/readyz") + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + + +def test_readyz_with_no_checks_is_ready(app: FastAPI, client: TestClient) -> None: + app.state.readiness_checks = [] + + response = client.get("/readyz") + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + + +def test_readyz_reports_failing_checks(app: FastAPI, client: TestClient) -> None: + def dependency_down(): + return False + + def dependency_raises(): + raise RuntimeError("boom") + + app.state.readiness_checks = [dependency_down, dependency_raises] + + response = client.get("/readyz") + assert response.status_code == 503 + detail = response.json()["detail"] + assert detail["dependency_down"] == "check returned a falsy result" + assert "boom" in detail["dependency_raises"] + + +def test_the_database_is_registered_as_a_readiness_check(app: FastAPI) -> None: + assert [c.__name__ for c in app.state.readiness_checks] == ["database_reachable"] diff --git a/backend/tests/test_logging_config.py b/backend/tests/test_logging_config.py new file mode 100644 index 00000000..f87769bb --- /dev/null +++ b/backend/tests/test_logging_config.py @@ -0,0 +1,40 @@ +""" +Tests for the structured logging formatters +""" + +import json +import logging + +from ref_backend.logging_config import JsonFormatter, TextFormatter + + +def _make_record(message="hello", **extra): + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg=message, + args=(), + exc_info=None, + ) + for key, value in extra.items(): + setattr(record, key, value) + return record + + +def test_json_formatter_produces_valid_json_with_extras(): + formatter = JsonFormatter() + record = _make_record(request_id="abc123") + payload = json.loads(formatter.format(record)) + assert payload["message"] == "hello" + assert payload["request_id"] == "abc123" + assert payload["level"] == "info" + + +def test_text_formatter_appends_extras_as_key_value_pairs(): + formatter = TextFormatter() + record = _make_record(request_id="abc123") + line = formatter.format(record) + assert "hello" in line + assert "request_id='abc123'" in line diff --git a/backend/tests/test_metrics.py b/backend/tests/test_metrics.py new file mode 100644 index 00000000..63b379a2 --- /dev/null +++ b/backend/tests/test_metrics.py @@ -0,0 +1,14 @@ +""" +Tests for the Prometheus metrics endpoint +""" + +from fastapi.testclient import TestClient + + +def test_metrics_exposes_standard_names(client: TestClient, settings) -> None: + client.get(f"{settings.API_V1_STR}/diagnostics/") + + response = client.get("/metrics") + assert response.status_code == 200 + assert "http_requests_total" in response.text + assert "http_request_duration_seconds" in response.text diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py new file mode 100644 index 00000000..1ada1afc --- /dev/null +++ b/backend/tests/test_middleware.py @@ -0,0 +1,54 @@ +""" +Tests for the wide-event HTTP middleware +""" + +from fastapi.testclient import TestClient + + +def test_wide_event_stamps_request_id_header(client: TestClient) -> None: + response = client.get("/livez") + assert "x-request-id" in response.headers + assert "x-process-time" in response.headers + + +def test_wide_event_reuses_upstream_request_id(client: TestClient) -> None: + response = client.get("/livez", headers={"x-request-id": "upstream-id"}) + assert response.headers["x-request-id"] == "upstream-id" + + +def test_wide_event_reads_forwarded_for_header(client: TestClient) -> None: + response = client.get("/livez", headers={"x-forwarded-for": "203.0.113.5, 10.0.0.1"}) + assert response.status_code == 200 + + +def test_wide_event_logs_the_request(client: TestClient, settings, caplog) -> None: + with caplog.at_level("INFO", logger="access"): + client.get(f"{settings.API_V1_STR}/diagnostics/") + + record = next(r for r in caplog.records if r.name == "access") + assert record.event == "http_request" + assert record.method == "GET" + assert record.path == f"{settings.API_V1_STR}/diagnostics/" + assert record.status == 200 + + +def test_wide_event_logs_and_reraises_on_unhandled_exception(app, caplog) -> None: + @app.get("/boom") + def boom(): + raise RuntimeError("boom") + + with TestClient(app, raise_server_exceptions=False) as c, caplog.at_level("ERROR", logger="access"): + response = c.get("/boom") + + assert response.status_code == 500 + record = next(r for r in caplog.records if r.name == "access") + assert record.error_type == "RuntimeError" + + +def test_wide_event_logs_probe_hits_at_debug(client: TestClient, caplog) -> None: + with caplog.at_level("DEBUG", logger="access"): + client.get("/livez") + + record = next(r for r in caplog.records if r.name == "access") + assert record.levelname == "DEBUG" + assert record.path == "/livez" diff --git a/backend/tests/test_spa_fallback.py b/backend/tests/test_spa_fallback.py index 0af730e6..b89260d6 100644 --- a/backend/tests/test_spa_fallback.py +++ b/backend/tests/test_spa_fallback.py @@ -13,7 +13,9 @@ from fastapi import FastAPI from starlette.testclient import TestClient -from ref_backend.builder import SPAStaticFiles +from ref_backend import testing +from ref_backend.api import deps +from ref_backend.builder import SPAStaticFiles, build_app @pytest.fixture() @@ -90,3 +92,29 @@ def test_api_route_not_affected(self, spa_client: TestClient): r = spa_client.get("/api/v1/health") assert r.status_code == 200 assert r.json() == {"ok": True} + + +class TestOperationalRoutesSurviveTheMount: + """ + The mount answers every path, so the operational routes have to be registered ahead of it. + """ + + @pytest.fixture() + def mounted_client(self, static_dir): + settings = testing.test_settings().model_copy(update={"STATIC_DIR": str(static_dir)}) + app = build_app( + settings=settings, + ref_config=testing.test_ref_config(), + database=deps._get_database_dependency(testing.test_settings(), testing.test_ref_config()), + ) + with TestClient(app) as c: + yield c + + @pytest.mark.parametrize("path", ["/livez", "/readyz", "/deploy/info", "/metrics"]) + def test_route_is_not_shadowed(self, mounted_client: TestClient, path: str): + assert mounted_client.get(path).status_code == 200 + + def test_the_mount_still_serves_the_spa(self, mounted_client: TestClient): + r = mounted_client.get("/diagnostics") + assert r.status_code == 200 + assert "SPA Root" in r.text diff --git a/backend/uv.lock b/backend/uv.lock index b49d1f6b..c3caae8e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2499,6 +2499,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f4/cdcebf7094b03b99fba71ac8f56bd6f227973642662f49d272332d8419b3/prometheus_fastapi_instrumentator-8.1.0.tar.gz", hash = "sha256:b77f3043665e8d28e2bbd21017506195a43d9adf1d402d01bf95b494b7e560e1", size = 20492, upload-time = "2026-07-26T11:12:44.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.53" @@ -3412,7 +3434,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastapi-sqlalchemy-monitor" }, { name = "httpx" }, - { name = "loguru" }, + { name = "prometheus-fastapi-instrumentator" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -3439,7 +3461,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "fastapi-sqlalchemy-monitor", specifier = ">=1.1.3" }, { name = "httpx", specifier = ">=0.27" }, - { name = "loguru" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=7.0.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" }, { name = "pydantic", specifier = ">2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1,<3.0.0" }, diff --git a/changelog/110.breaking.md b/changelog/110.breaking.md new file mode 100644 index 00000000..1a234faf --- /dev/null +++ b/changelog/110.breaking.md @@ -0,0 +1,2 @@ +Removed `GET /api/v1/utils/health-check/`, which nothing called. +Use `/livez` and `/readyz` instead, which report process liveness and whether the REF database still answers. diff --git a/changelog/110.feature.md b/changelog/110.feature.md new file mode 100644 index 00000000..e35c8904 --- /dev/null +++ b/changelog/110.feature.md @@ -0,0 +1,6 @@ +Adopted the observability setup from the `copier-python-service` template. +Every request now emits a single JSON log line carrying the method, path, status, duration and a `request_id`, +and Prometheus metrics are served from `/metrics`. +This also adds the operational routes the template ships: +`/livez` and `/readyz` for orchestrator probes, and `/deploy/info` for the image build stamps. +Set `LOG_FORMAT=text` for readable logs during local development. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..62414342 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,104 @@ +# Observability + +What the REF App emits and where it lands. +The shape of this comes from the `copier-python-service` template, so it lines up with the other +Climate Resource services. +The cross-app picture lives in `docs/research/app-observability-baseline.md` in the +`infrastructure` repository. + +## What ships + +| Pillar | Mechanism | Status | +| ------- | -------------------------------------------------- | --------------------------- | +| Metrics | `/metrics` via `prometheus-fastapi-instrumentator` | Implemented | +| Logs | JSON wide events on stdout, scraped by the cluster | Implemented | +| Errors | Sentry, on once `SENTRY_DSN` is set | Implemented, off by default | +| Traces | OpenTelemetry | Not wired up | + +## Wide events + +Every HTTP request produces one JSON log line on the `access` logger. +Fields: + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------- | +| `event` | Always `http_request`. | +| `request_id` | From `x-request-id`, or minted if absent. | +| `method` | HTTP method. | +| `path` | Request path. | +| `query` | Query parameters. | +| `status` | Response status code. | +| `duration_ms` | Wall-clock request time. | +| `response_bytes` | Response body size. | +| `client_ip` | From `x-forwarded-for`, falling back to the socket peer. | +| `user_agent` | Request header. | +| `referer` | Request header. | +| `sentry_trace_id` | Present when Sentry is configured, for cross-tool correlation. | +| `error_type` | Exception class name, present only on an unhandled exception. | + +Every log line also carries environment context: `service`, `version`, `commit`, `env` and +`instance_id`. + +Every request logs at `info`, except an unhandled exception, which logs at `error`. +A successful hit on `/livez`, `/readyz` or `/metrics` logs at `debug`, so the probes do not +drown out real traffic. + +`request_id` ties the pillars together. +The response carries it in `x-request-id`, the wide event carries it alongside `sentry_trace_id`, +and Sentry events carry it as the `request_id` tag. +Metrics are aggregates, so they link by `path` and `status` rather than by request. + +## Log format + +`LOG_FORMAT=json` is the default and is what the cluster expects. +Set `LOG_FORMAT=text` for local development to get a human-readable line +with the structured fields appended as `key=value` pairs. +`LOG_LEVEL` sets the root level and defaults to `INFO`. + +climate-ref and its providers log through loguru. +`configure_logging` re-points loguru at the standard library, so those records land in the same +stream with the same formatter rather than going out unstructured. + +## Health checks + +`GET /livez` answers that the process is alive. +It never touches a dependency, so it cannot go down because a dependency did. + +`GET /readyz` runs the checks on `app.state.readiness_checks` and returns `503` if any fail. +`build_app` registers one check, which proves the configured REF database still answers a query. +Register another by appending a zero-argument callable to that list. +A check signals trouble by returning a falsy value or by raising. +A check may be async, in which case what it returns is awaited. + +## Deployment information + +`GET /deploy/info` reports the version and the build stamps baked into the image: +`GIT_COMMIT`, `IMAGE_TAG` and `BUILD_TIME`. +These are the same values the wide events carry, so a log line can be traced back to an image. +It is an operator diagnostic and is left out of the schema. + +`GET /api/v1/utils/about` covers the user-facing side: the app and REF versions, and how fresh +the results are. + +## Metrics + +Two standard names, read by the generic Application dashboard: + +- `http_requests_total`, labelled by `method`, `path` and `status`. +- `http_request_duration_seconds`, a histogram labelled by `method` and `path`. + +A counter or histogram made with `prometheus_client` shows up on `/metrics` on its own. + +`/metrics`, `/livez`, `/readyz` and `/deploy/info` are all registered before the SPA static mount, +which otherwise answers every path. + +## Sentry + +Off until `SENTRY_DSN` is set, and skipped entirely when `ENVIRONMENT` is `local`. + +## Gaps + +- [ ] OpenTelemetry tracing, which the template ships but this app does not yet install. +- [ ] Pyroscope profiling, likewise. +- [ ] Readiness checks beyond the database, if the API grows another hard dependency. +- [ ] Service-specific alert rules and dashboards beyond the generic set. diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index 0e29368a..d98c541d 100644 --- a/frontend/src/client/@tanstack/react-query.gen.ts +++ b/frontend/src/client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from '../sdk.gen'; -import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsResponse, DatasetsGetData, DatasetsGetError, DatasetsGetResponse, DatasetsListData, DatasetsListError, DatasetsListResponse, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsResponse, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesResponse, DiagnosticsListResponse, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionError, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionResponse, ExecutionsGetData, ExecutionsGetError, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetResponse, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesResponse, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsResponse, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleResponse, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionResponse, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeResponse, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListThemesData, ExplorerListThemesResponse, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleResponse, ModelsGetData, ModelsGetError, ModelsGetResponse, ModelsListData, ModelsListError, ModelsListResponse, ResultsGetResultData, ResultsGetResultError, UtilsAboutData, UtilsAboutResponse, UtilsHealthCheckData, UtilsHealthCheckResponse } from '../types.gen'; +import { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout } from '../sdk.gen'; +import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsResponse, DatasetsGetData, DatasetsGetError, DatasetsGetResponse, DatasetsListData, DatasetsListError, DatasetsListResponse, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsResponse, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesResponse, DiagnosticsListResponse, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionError, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionResponse, ExecutionsGetData, ExecutionsGetError, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetResponse, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesResponse, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsResponse, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleResponse, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionResponse, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeResponse, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListThemesData, ExplorerListThemesResponse, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleResponse, ModelsGetData, ModelsGetError, ModelsGetResponse, ModelsListData, ModelsListError, ModelsListResponse, ResultsGetResultData, ResultsGetResultError, UtilsAboutData, UtilsAboutResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -849,24 +849,6 @@ export const resultsGetResultOptions = (options: Options) queryKey: resultsGetResultQueryKey(options) }); -export const utilsHealthCheckQueryKey = (options?: Options) => createQueryKey('utilsHealthCheck', options); - -/** - * Health Check - */ -export const utilsHealthCheckOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await utilsHealthCheck({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: utilsHealthCheckQueryKey(options) -}); - export const utilsAboutQueryKey = (options?: Options) => createQueryKey('utilsAbout', options); /** diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index d55dff68..02a89412 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from './sdk.gen'; -export type { About, AftCollectionCard, AftCollectionCardContent, AftCollectionContent, AftCollectionDetail, AftCollectionDiagnosticLink, AftCollectionFilterControl, AftCollectionGroupingConfig, AftCollectionPlainLanguage, AftCollectionSummary, AftDiagnosticDetail, AftDiagnosticSummary, ClientOptions, CmecMetric, Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, CmipDatasetMetadata, CollectionDataset, CollectionDatasetWritable, CollectionDiagnosticSummary, CollectionDiagnosticSummaryWritable, CollectionEnsembleComparison, CollectionEnsembleComparisonWritable, CollectionExecution, CollectionExecutionGroup, CollectionExecutionGroupWritable, CollectionExecutionWritable, CollectionModelSummary, CollectionModelSummaryWritable, Dataset, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsErrors, DatasetsExecutionsResponse, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetError, DatasetsGetErrors, DatasetsGetResponse, DatasetsGetResponses, DatasetsListData, DatasetsListError, DatasetsListErrors, DatasetsListResponse, DatasetsListResponses, DatasetWritable, DiagnosticRuns, DiagnosticRunsWritable, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetErrors, DiagnosticsGetResponse, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListError, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponse, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponse, DiagnosticsListMetricValuesResponses, DiagnosticsListResponse, DiagnosticsListResponses, DiagnosticSummary, EnsembleComparison, EnsembleComparisonWritable, EnsembleStatistics, Execution, ExecutionGroup, ExecutionOutput, ExecutionResourceSummary, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionError, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponse, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetError, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponse, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponse, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponse, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponse, ExecutionsMetricBundleResponses, ExecutionStats, ExecutionStatsWritable, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionErrors, ExplorerGetCollectionResponse, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeErrors, ExplorerGetThemeResponse, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponse, ExplorerListThemesResponses, Facet, FailedRun, GroupBy, HttpValidationError, MetricDimensions, MetricValueCollection, MetricValueFacetSummary, MetricValueType, ModelDetail, ModelDetailWritable, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleErrors, ModelsEnsembleResponse, ModelsEnsembleResponses, ModelsGetData, ModelsGetError, ModelsGetErrors, ModelsGetResponse, ModelsGetResponses, ModelsListData, ModelsListError, ModelsListErrors, ModelsListResponse, ModelsListResponses, ModelSummary, ModelSummaryWritable, ProviderSummary, RefDiagnosticLink, ReferenceDatasetLink, ResultOutputType, ResultsGetResultData, ResultsGetResultError, ResultsGetResultErrors, ResultsGetResultResponses, RunCounts, RunCountsWritable, ScalarValue, SeriesValue, ThemeDetail, ThemeSummary, UtilsAboutData, UtilsAboutResponse, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponse, UtilsHealthCheckResponses, ValidationError } from './types.gen'; +export { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout } from './sdk.gen'; +export type { About, AftCollectionCard, AftCollectionCardContent, AftCollectionContent, AftCollectionDetail, AftCollectionDiagnosticLink, AftCollectionFilterControl, AftCollectionGroupingConfig, AftCollectionPlainLanguage, AftCollectionSummary, AftDiagnosticDetail, AftDiagnosticSummary, ClientOptions, CmecMetric, Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, CmipDatasetMetadata, CollectionDataset, CollectionDatasetWritable, CollectionDiagnosticSummary, CollectionDiagnosticSummaryWritable, CollectionEnsembleComparison, CollectionEnsembleComparisonWritable, CollectionExecution, CollectionExecutionGroup, CollectionExecutionGroupWritable, CollectionExecutionWritable, CollectionModelSummary, CollectionModelSummaryWritable, Dataset, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsErrors, DatasetsExecutionsResponse, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetError, DatasetsGetErrors, DatasetsGetResponse, DatasetsGetResponses, DatasetsListData, DatasetsListError, DatasetsListErrors, DatasetsListResponse, DatasetsListResponses, DatasetWritable, DiagnosticRuns, DiagnosticRunsWritable, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetErrors, DiagnosticsGetResponse, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListError, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponse, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponse, DiagnosticsListMetricValuesResponses, DiagnosticsListResponse, DiagnosticsListResponses, DiagnosticSummary, EnsembleComparison, EnsembleComparisonWritable, EnsembleStatistics, Execution, ExecutionGroup, ExecutionOutput, ExecutionResourceSummary, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionError, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponse, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetError, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponse, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponse, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponse, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponse, ExecutionsMetricBundleResponses, ExecutionStats, ExecutionStatsWritable, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionErrors, ExplorerGetCollectionResponse, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeErrors, ExplorerGetThemeResponse, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponse, ExplorerListThemesResponses, Facet, FailedRun, GroupBy, HttpValidationError, MetricDimensions, MetricValueCollection, MetricValueFacetSummary, MetricValueType, ModelDetail, ModelDetailWritable, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleErrors, ModelsEnsembleResponse, ModelsEnsembleResponses, ModelsGetData, ModelsGetError, ModelsGetErrors, ModelsGetResponse, ModelsGetResponses, ModelsListData, ModelsListError, ModelsListErrors, ModelsListResponse, ModelsListResponses, ModelSummary, ModelSummaryWritable, ProviderSummary, RefDiagnosticLink, ReferenceDatasetLink, ResultOutputType, ResultsGetResultData, ResultsGetResultError, ResultsGetResultErrors, ResultsGetResultResponses, RunCounts, RunCountsWritable, ScalarValue, SeriesValue, ThemeDetail, ThemeSummary, UtilsAboutData, UtilsAboutResponse, UtilsAboutResponses, ValidationError } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ef7a3ef4..73939689 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, DatasetsExecutionsData, DatasetsExecutionsErrors, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetErrors, DatasetsGetResponses, DatasetsListData, DatasetsListErrors, DatasetsListResponses, DiagnosticsFacetsData, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetErrors, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponses, DiagnosticsListResponses, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponses, ExplorerGetCollectionData, ExplorerGetCollectionErrors, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeErrors, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponses, ModelsEnsembleData, ModelsEnsembleErrors, ModelsEnsembleResponses, ModelsGetData, ModelsGetErrors, ModelsGetResponses, ModelsListData, ModelsListErrors, ModelsListResponses, ResultsGetResultData, ResultsGetResultErrors, ResultsGetResultResponses, UtilsAboutData, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponses } from './types.gen'; +import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, DatasetsExecutionsData, DatasetsExecutionsErrors, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetErrors, DatasetsGetResponses, DatasetsListData, DatasetsListErrors, DatasetsListResponses, DiagnosticsFacetsData, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetErrors, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponses, DiagnosticsListResponses, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponses, ExplorerGetCollectionData, ExplorerGetCollectionErrors, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeErrors, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponses, ModelsEnsembleData, ModelsEnsembleErrors, ModelsEnsembleResponses, ModelsGetData, ModelsGetErrors, ModelsGetResponses, ModelsListData, ModelsListErrors, ModelsListResponses, ResultsGetResultData, ResultsGetResultErrors, ResultsGetResultResponses, UtilsAboutData, UtilsAboutResponses } from './types.gen'; export type Options = Options2 & { /** @@ -250,11 +250,6 @@ export const modelsEnsemble = (options: Op */ export const resultsGetResult = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/results/{result_id}', ...options }); -/** - * Health Check - */ -export const utilsHealthCheck = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/utils/health-check/', ...options }); - /** * About * diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 24cf40f4..ed1c3197 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -2893,24 +2893,6 @@ export type ResultsGetResultResponses = { 200: unknown; }; -export type UtilsHealthCheckData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/utils/health-check/'; -}; - -export type UtilsHealthCheckResponses = { - /** - * Response Utils-Health Check - * - * Successful Response - */ - 200: boolean; -}; - -export type UtilsHealthCheckResponse = UtilsHealthCheckResponses[keyof UtilsHealthCheckResponses]; - export type UtilsAboutData = { body?: never; path?: never; diff --git a/scripts/generate-client-sdk.sh b/scripts/generate-client-sdk.sh index a3c0d211..3febd79f 100644 --- a/scripts/generate-client-sdk.sh +++ b/scripts/generate-client-sdk.sh @@ -6,8 +6,12 @@ set -x cd "$(dirname "$0")/.." +# The app logs to stdout, so the schema is written to the file directly rather than piped. pushd backend -uv run python -c "import ref_backend.main; import json; print(json.dumps(ref_backend.main.app.openapi()))" > ../frontend/openapi.json +uv run python -c " +import json, pathlib, ref_backend.main +pathlib.Path('../frontend/openapi.json').write_text(json.dumps(ref_backend.main.app.openapi())) +" popd # openapi-ts needs the TypeScript 5 compiler API, which TypeScript 7 does not ship.