Source code for src.core.routes.docs

# Copyright (C) 2025 Raccoon Survey org
# This file is part of Raccoon Survey.
# Raccoon Survey is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License v3 as published by
# the Free Software Foundation.
# See the LICENSE file distributed with this program for details.

from __future__ import annotations

import json
import os
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from flask import Blueprint, jsonify

bp = Blueprint("docs", __name__)


def _fallback_spec() -> dict:
    """
    Fallback specification for the Raccoon Survey API.

    Returns:
        dict: Fallback specification for the Raccoon Survey API.
    """
    return {
        "openapi": "3.0.3",
        "info": {
            "title": "Raccoon Survey API",
            "version": _resolve_version("1.12.12"),
            "description": "FallBack",
        },
        "servers": [{"url": "/api/v1"}],
        "paths": {
            "/auth/login": {
                "post": {
                    "tags": ["Auth"],
                    "summary": "Iniciar sesión",
                    "requestBody": {
                        "required": True,
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "object",
                                    "required": ["email", "password"],
                                    "properties": {
                                        "email": {"type": "string", "format": "email"},
                                        "password": {"type": "string"},
                                    },
                                }
                            }
                        },
                    },
                    "responses": {
                        "200": {"description": "Tokens generados"},
                        "400": {"description": "Email y contraseña requeridos"},
                        "401": {"description": "Credenciales inválidas"},
                        "403": {"description": "No autorizado"},
                        "404": {"description": "Usuario no encontrado"},
                    },
                }
            },
            "/auth/me": {
                "get": {
                    "tags": ["Auth"],
                    "summary": "Perfil del usuario",
                    "security": [{"bearerAuth": []}],
                    "responses": {
                        "200": {"description": "Perfil con id, role, team_id, name"}
                    },
                }
            },
        },
        "components": {
            "securitySchemes": {
                "bearerAuth": {
                    "type": "http",
                    "scheme": "bearer",
                    "bearerFormat": "JWT",
                }
            }
        },
    }


_latest_version_cache: str | None = None
_latest_version_cache_ts: float = 0.0
_CACHE_TTL_SECONDS = 600  # 10 minutes


def _parse_semver(name: str) -> tuple[int, int, int]:
    """Parse a tag name into a SemVer-like tuple for sorting.

    Non-numeric parts are treated as zeros.

    Args:
        name (str): The tag name to parse.

    Returns:
        tuple[int, int, int]: A tuple of three integers representing the SemVer version.
    """
    s = name.strip().lstrip("vV")
    parts = s.replace("-", ".").split(".")
    nums: list[int] = []
    for p in parts[:3]:
        try:
            nums.append(int(p))
        except ValueError:
            nums.append(0)

    while len(nums) < 3:
        nums.append(0)

    return nums[0], nums[1], nums[2]


def _get_latest_github_tag(repo: str = "Javi-CD/raccoon-survey") -> str | None:
    """Fetch the latest tag name from GitHub.

    Tries to sort tags by SemVer and returns the highest.

    Args:
        repo (str, optional): The GitHub repository to fetch tags from. Defaults to "Javi-CD/raccoon-survey".

    Returns:
        str | None: The latest tag name, or None if not found or an error occurs.
    """
    url = f"https://api.github.com/repos/{repo}/tags"
    headers = {"Accept": "application/vnd.github+json"}
    token = os.getenv("GITHUB_TOKEN")
    if token:
        headers["Authorization"] = f"Bearer {token}"

    try:
        req = Request(url, headers=headers, method="GET")  # noqa: S310
        with urlopen(req, timeout=3) as resp:  # noqa: S310
            body = resp.read()

        tags = json.loads(body.decode("utf-8"))
        if isinstance(tags, list) and tags:
            try:
                tags_sorted = sorted(
                    tags,
                    key=lambda t: _parse_semver(str(t.get("name", ""))),
                    reverse=True,
                )
                name = str(tags_sorted[0].get("name", "")).strip()
            except Exception:
                name = str(tags[0].get("name", "")).strip()

            return name or None

        return None
    except (URLError, HTTPError, TimeoutError, Exception):
        return None


def _resolve_version(current_version: str) -> str:
    """Resolve the API version using the latest GitHub tag, with cache/fallback.

    Args:
        current_version (str): The current version string.

    Returns:
        str: The resolved version string.
    """
    global _latest_version_cache, _latest_version_cache_ts
    now = time.time()

    if _latest_version_cache and (now - _latest_version_cache_ts) < _CACHE_TTL_SECONDS:
        return _latest_version_cache

    tag = _get_latest_github_tag()
    if tag:
        version = tag.lstrip("vV")
        _latest_version_cache = version
        _latest_version_cache_ts = now

        return version

    return current_version or "1.0.0"


[docs] @bp.get("/openapi.json") def openapi_spec(): """Serve the OpenAPI specification as JSON. Returns: JSON: The OpenAPI document. """ try: spec_path = Path(__file__).resolve().parent.parent / "openapi.json" with spec_path.open("r", encoding="utf-8") as f: data = json.load(f) current_version = str(data.get("info", {}).get("version", "")) resolved_version = _resolve_version(current_version) data.setdefault("info", {}) data["info"]["version"] = resolved_version return jsonify(data), 200 except FileNotFoundError: return jsonify(_fallback_spec()), 200 except json.JSONDecodeError: return jsonify(_fallback_spec()), 200 except Exception as e: return jsonify({"message": str(e)}), 500