Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"ReleaseResponse",
"ReleaseDetailsResponse",
"ReleasePublicResponse",
"LatestVersionResponse",
"ReleaseCreate",
"ReleaseUpdate",
"PaginatedResponse",
Expand Down Expand Up @@ -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"""

Expand Down
91 changes: 84 additions & 7 deletions src/modules/api/public.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"],
Expand All @@ -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,
Expand All @@ -52,17 +113,14 @@ 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(
"[API] Public: Releases found in cache (offset=%i, limit=%i): %i releases | latest: %s",
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:
Expand All @@ -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

Expand All @@ -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),
Expand All @@ -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
176 changes: 175 additions & 1 deletion src/tests/api/test_api_public.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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"}
Loading