From 045d1fa00c779e079354c01127b26f8436674272 Mon Sep 17 00:00:00 2001 From: DmitryBurnaev Date: Wed, 29 Apr 2026 11:38:04 +0300 Subject: [PATCH] [#19] Add simple latest version API: expose format response - add public latest release endpoint with json and plain response formats - reuse active releases cache page for latest version lookup without analytics logging - cover latest version responses, cache fallback, errors, and OpenAPI enum schema --- src/models.py | 7 ++ src/modules/api/public.py | 91 ++++++++++++++-- src/tests/api/test_api_public.py | 176 ++++++++++++++++++++++++++++++- 3 files changed, 266 insertions(+), 8 deletions(-) diff --git a/src/models.py b/src/models.py index b5d4e5e..bd27cc8 100644 --- a/src/models.py +++ b/src/models.py @@ -10,6 +10,7 @@ "ReleaseResponse", "ReleaseDetailsResponse", "ReleasePublicResponse", + "LatestVersionResponse", "ReleaseCreate", "ReleaseUpdate", "PaginatedResponse", @@ -41,6 +42,12 @@ class ReleasePublicResponse(BaseModel): published_at: datetime +class LatestVersionResponse(BaseModel): + """Latest release version response model for API""" + + version: str + + class ReleaseBaseResponse(BaseModel): """Release details response model for API""" diff --git a/src/modules/api/public.py b/src/modules/api/public.py index a16f6a8..41638ed 100644 --- a/src/modules/api/public.py +++ b/src/modules/api/public.py @@ -1,13 +1,17 @@ import logging import time +from enum import StrEnum from typing import Any from fastapi import APIRouter, Query, Request +from pydantic import ValidationError from starlette.background import BackgroundTasks +from starlette.responses import PlainTextResponse from src.constants import CACHE_KEY_ACTIVE_RELEASES_PAGE from src.db.clickhouse import ReleasesAnalyticsSchema -from src.models import ReleasePublicResponse, PaginatedResponse +from src.exceptions import InstanceLookupError +from src.models import LatestVersionResponse, ReleasePublicResponse, PaginatedResponse from src.modules.api import ErrorHandlingBaseRoute from src.db.repositories import ReleaseRepository from src.db.services import SASessionUOW @@ -21,6 +25,21 @@ __all__ = ("public_router",) +class _LatestVersionFormat(StrEnum): + JSON = "json" + PLAIN = "plain" + + @classmethod + def _missing_(cls, value: object) -> "_LatestVersionFormat | None": + if isinstance(value, str): + normalized_value = value.lower() + for member in cls: + if member.value == normalized_value: + return member + + return None + + public_router = APIRouter( prefix="/releases", tags=["public"], @@ -29,6 +48,48 @@ ) +@public_router.get("/latest", response_model=LatestVersionResponse) +async def get_latest_release_version( + response_format: _LatestVersionFormat = Query( + _LatestVersionFormat.JSON, + alias="format", + description="Response format", + ), +) -> LatestVersionResponse | PlainTextResponse: + """Get the latest active release version (public endpoint, no analytics tracking).""" + offset = 0 + limit = 1 + cache_key = CACHE_KEY_ACTIVE_RELEASES_PAGE.format(offset=offset, limit=limit) + cache: CacheProtocol = get_cache() + response_result: PaginatedResponse[ReleasePublicResponse] | None = None + + settings = get_app_settings() + if settings.flags.api_cache_enabled: + response_result = _get_cached_release_page(await cache.get(cache_key)) + + if response_result is None: + logger.debug("[API] Public: Latest release not found in cache, getting from database") + async with SASessionUOW() as uow: + repo = ReleaseRepository(session=uow.session) + releases, total = await repo.get_active_releases(offset=offset, limit=limit) + response_result = PaginatedResponse[ReleasePublicResponse]( + items=[ReleasePublicResponse.model_validate(release) for release in releases], + total=total, + offset=offset, + limit=limit, + ) + await cache.set(cache_key, response_result.model_dump(mode="json")) + + version = _get_latest_version(response_result) + if version is None: + raise InstanceLookupError("No active release found") + + if response_format == _LatestVersionFormat.PLAIN: + return PlainTextResponse(version) + + return LatestVersionResponse(version=version) + + @public_router.get("/", response_model=PaginatedResponse[ReleasePublicResponse]) async def get_active_releases( request: Request, @@ -52,9 +113,6 @@ async def get_active_releases( cached_data = await cache.get(cache_key) cached_result = cached_data if cached_data and isinstance(cached_data, dict) else None - def get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse]) -> str | None: - return response_result.items[0].version if response_result.items else None - if cached_result: response_result = PaginatedResponse[ReleasePublicResponse].model_validate(cached_result) logger.info( @@ -62,7 +120,7 @@ def get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse] offset, limit, len(response_result.items), - get_latest_version(response_result) or "N/A", + _get_latest_version(response_result) or "N/A", ) response_status = 200 else: @@ -86,7 +144,7 @@ def get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse] "[API] Public: Releases got from DB and cached: %i releases | total: %i | latest: %s", len(response_result.items), total, - get_latest_version(response_result) or "N/A", + _get_latest_version(response_result) or "N/A", ) response_status = 200 @@ -105,7 +163,7 @@ def get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse] client_ip_address=request.client.host if request.client else None, client_user_agent=request.headers.get("user-agent"), client_ref_url=request.headers.get("referer"), - response_latest_version=get_latest_version(response_result), + response_latest_version=_get_latest_version(response_result), response_status=response_status, response_time_ms=(time.time() - start_time) * 1000, response_from_cache=bool(cached_result), @@ -115,3 +173,22 @@ def get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse] logger.debug("[API] Public: Analytics disabled, skipping log request") return response_result + + +def _get_latest_version(response_result: PaginatedResponse[ReleasePublicResponse]) -> str | None: + """Get latest release version from a paginated release response.""" + return response_result.items[0].version if response_result.items else None + + +def _get_cached_release_page( + cached_data: Any, +) -> PaginatedResponse[ReleasePublicResponse] | None: + """Validate cached release page payload and ignore unusable cache entries.""" + if not cached_data or not isinstance(cached_data, dict): + return None + + try: + return PaginatedResponse[ReleasePublicResponse].model_validate(cached_data) + except ValidationError: + logger.warning("[API] Public: Invalid active releases cache payload ignored") + return None diff --git a/src/tests/api/test_api_public.py b/src/tests/api/test_api_public.py index 3b8910a..7a2784a 100644 --- a/src/tests/api/test_api_public.py +++ b/src/tests/api/test_api_public.py @@ -1,5 +1,5 @@ from typing import Generator, Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from starlette.testclient import TestClient @@ -8,6 +8,22 @@ from src.db.clickhouse import ReleasesAnalyticsSchema +def make_latest_cache_payload(version: str = "2026.3.4") -> dict[str, Any]: + return { + "items": [ + { + "version": version, + "notes": "## Latest", + "url": None, + "published_at": "2026-03-04T12:00:00", + } + ], + "total": 1, + "offset": 0, + "limit": 1, + } + + @pytest.fixture(autouse=True) def mock_log_analytics() -> Generator[MagicMock, None, None]: with patch("src.services.analytics.AnalyticsService.log_request_async") as mock_log: @@ -37,6 +53,16 @@ def mock_cached_releases() -> Generator[MagicMock, None, None]: yield mock_cache +@pytest.fixture +def mock_release_cache() -> Generator[MagicMock, None, None]: + with patch("src.modules.api.public.get_cache") as mock_get_cache: + cache = MagicMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + mock_get_cache.return_value = cache + yield cache + + class TestPublicReleasesAPI: """Test public releases API endpoint with analytics logging""" @@ -141,3 +167,151 @@ def test_analytics_service_called_with_optional_params( assert request.client_version is None assert request.client_install_id is None assert request.client_is_corporate is None + + def test_get_latest_version_json_by_default( + self, + mock_release_cache: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint returns JSON by default""" + mock_release_cache.get.return_value = make_latest_cache_payload("2026.3.4") + + response = client.get("/public/releases/latest") + + assert response.status_code == 200 + assert response.json() == {"version": "2026.3.4"} + assert response.headers["content-type"] == "application/json" + + def test_get_latest_version_plain( + self, + mock_release_cache: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint can return a plain text response""" + mock_release_cache.get.return_value = make_latest_cache_payload("2026.3.4") + + response = client.get("/public/releases/latest?format=plain") + + assert response.status_code == 200 + assert response.text == "2026.3.4" + assert response.headers["content-type"].startswith("text/plain") + + def test_get_latest_version_format_is_case_insensitive( + self, + mock_release_cache: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint accepts uppercase format values""" + mock_release_cache.get.return_value = make_latest_cache_payload("2026.3.4") + + response = client.get("/public/releases/latest?format=JSON") + + assert response.status_code == 200 + assert response.json() == {"version": "2026.3.4"} + + def test_get_latest_version_uses_page_one_cache( + self, + mock_release_cache: MagicMock, + mock_cached_releases: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint reads the active releases page cache first""" + mock_release_cache.get.return_value = make_latest_cache_payload("2026.3.4") + + response = client.get("/public/releases/latest") + + assert response.status_code == 200 + mock_release_cache.get.assert_awaited_once_with("active_releases_page_0_1") + mock_release_cache.set.assert_not_awaited() + mock_cached_releases.assert_not_awaited() + + def test_get_latest_version_falls_back_to_database_on_cache_miss( + self, + mock_release_cache: MagicMock, + mock_cached_releases: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint queries DB and caches the page on cache miss""" + mock_release_cache.get.return_value = None + + response = client.get("/public/releases/latest") + + assert response.status_code == 200 + assert response.json() == {"version": "2025.12.100"} + mock_cached_releases.assert_awaited_once_with(offset=0, limit=1) + mock_release_cache.set.assert_awaited_once() + cache_key, cache_payload = mock_release_cache.set.await_args.args + assert cache_key == "active_releases_page_0_1" + assert cache_payload["items"][0]["version"] == "2025.12.100" + assert cache_payload["offset"] == 0 + assert cache_payload["limit"] == 1 + + def test_get_latest_version_falls_back_to_database_on_invalid_cache( + self, + mock_release_cache: MagicMock, + mock_cached_releases: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint ignores invalid cached payloads""" + mock_release_cache.get.return_value = {"items": "invalid"} + + response = client.get("/public/releases/latest") + + assert response.status_code == 200 + assert response.json() == {"version": "2025.12.100"} + mock_cached_releases.assert_awaited_once_with(offset=0, limit=1) + mock_release_cache.set.assert_awaited_once() + + def test_get_latest_version_does_not_log_analytics( + self, + mock_log_analytics: MagicMock, + mock_release_cache: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint does not log ClickHouse analytics""" + mock_release_cache.get.return_value = make_latest_cache_payload("2026.3.4") + + response = client.get("/public/releases/latest") + + assert response.status_code == 200 + mock_log_analytics.assert_not_called() + + def test_get_latest_version_returns_404_without_active_releases( + self, + mock_release_cache: MagicMock, + mock_cached_releases: MagicMock, + client: TestClient, + ) -> None: + """Test latest version endpoint returns 404 when there are no active releases""" + mock_release_cache.get.return_value = None + mock_cached_releases.return_value = ([], 0) + + response = client.get("/public/releases/latest") + + assert response.status_code == 404 + assert response.json()["detail"] == "No active release found" + + def test_get_latest_version_rejects_invalid_format(self, client: TestClient) -> None: + """Test latest version endpoint rejects unsupported response formats""" + response = client.get("/public/releases/latest?format=xml") + + assert response.status_code == 422 + + def test_latest_version_format_is_openapi_enum(self, client: TestClient) -> None: + """Test latest version response format is documented as an enum""" + response = client.get("/openapi.json") + + assert response.status_code == 200 + schema = response.json() + parameters = schema["paths"]["/public/releases/latest"]["get"]["parameters"] + format_parameter = next(param for param in parameters if param["name"] == "format") + format_schema = format_parameter["schema"] + + if "$ref" in format_schema: + enum_schema = schema["components"]["schemas"][format_schema["$ref"].rsplit("/", 1)[1]] + else: + enum_reference = format_schema["allOf"][0]["$ref"] + enum_schema = schema["components"]["schemas"][enum_reference.rsplit("/", 1)[1]] + + assert enum_schema["type"] == "string" + assert set(enum_schema["enum"]) == {"json", "plain"}