diff --git a/app/ai-service/config/legacy_redirects.yaml b/app/ai-service/config/legacy_redirects.yaml new file mode 100644 index 00000000..a5481e43 --- /dev/null +++ b/app/ai-service/config/legacy_redirects.yaml @@ -0,0 +1,11 @@ +legacy_to_v1: + "/ai/inference": "/v1/ai/inference" + "/ai/proof-of-life": "/v1/ai/proof-of-life" + "/ai/anonymize": "/v1/ai/anonymize" + "/ai/humanitarian/verify": "/v1/ai/humanitarian/verify" + +legacy_prefix_map: + - legacy_prefix: "/ai/status/" + v1_prefix: "/v1/ai/status/" + - legacy_prefix: "/ai/task/" + v1_prefix: "/v1/ai/task/" diff --git a/app/ai-service/main.py b/app/ai-service/main.py index b067e438..0c5a5e79 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -250,17 +250,49 @@ async def _log_rejection( # the ocr_router) need an explicit redirect entry here. The OCR route is # still served by the legacy router above so no redirect is needed for it. # --------------------------------------------------------------------------- -_LEGACY_TO_V1: dict = { - "/ai/inference": "/v1/ai/inference", - "/ai/proof-of-life": "/v1/ai/proof-of-life", - "/ai/anonymize": "/v1/ai/anonymize", - "/ai/humanitarian/verify": "/v1/ai/humanitarian/verify", -} - -# Prefix-based redirects for parameterised routes (matched in order). +import os +from typing import Dict, List, Tuple, Type +from pydantic import BaseModel +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) + +class LegacyPrefixMapItem(BaseModel): + legacy_prefix: str + v1_prefix: str + +class LegacyRedirectsConfig(BaseSettings): + legacy_to_v1: Dict[str, str] + legacy_prefix_map: List[LegacyPrefixMapItem] + + model_config = SettingsConfigDict( + yaml_file=os.path.join(os.path.dirname(__file__), "config", "legacy_redirects.yaml"), + yaml_file_encoding='utf-8' + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + return (YamlConfigSettingsSource(settings_cls),) + +_legacy_yaml_path = os.path.join(os.path.dirname(__file__), "config", "legacy_redirects.yaml") +if not os.path.exists(_legacy_yaml_path): + raise RuntimeError(f"Required configuration file not found: {_legacy_yaml_path}") + +_legacy_config = LegacyRedirectsConfig() + +_LEGACY_TO_V1: dict = _legacy_config.legacy_to_v1 _LEGACY_PREFIX_MAP: list = [ - ("/ai/status/", "/v1/ai/status/"), - ("/ai/task/", "/v1/ai/task/"), + (item.legacy_prefix, item.v1_prefix) for item in _legacy_config.legacy_prefix_map ] diff --git a/app/ai-service/requirements-prod.txt b/app/ai-service/requirements-prod.txt index bc945c50..52046624 100644 --- a/app/ai-service/requirements-prod.txt +++ b/app/ai-service/requirements-prod.txt @@ -7,8 +7,8 @@ uvicorn[standard]==0.27.0 gunicorn==21.2.0 # Data validation and settings management -pydantic==2.5.3 -pydantic-settings==2.1.0 +pydantic==2.9.2 +pydantic-settings[yaml]==2.6.1 # Environment variable management python-dotenv==1.0.0 diff --git a/app/ai-service/requirements.txt b/app/ai-service/requirements.txt index c45f9070..2f95be39 100644 --- a/app/ai-service/requirements.txt +++ b/app/ai-service/requirements.txt @@ -8,8 +8,8 @@ uvicorn[standard]==0.27.0 python-multipart==0.0.6 # Data validation and settings management -pydantic==2.5.3 -pydantic-settings==2.1.0 +pydantic==2.9.2 +pydantic-settings[yaml]==2.6.1 # Environment variable management python-dotenv==1.0.0 diff --git a/app/ai-service/tests/test_legacy_redirects.py b/app/ai-service/tests/test_legacy_redirects.py new file mode 100644 index 00000000..a9e80220 --- /dev/null +++ b/app/ai-service/tests/test_legacy_redirects.py @@ -0,0 +1,78 @@ +import os +import yaml +import pytest +from fastapi.routing import APIRoute +from main import app, _legacy_yaml_path, _LEGACY_TO_V1, _LEGACY_PREFIX_MAP + +def test_yaml_file_exists_and_loads(): + assert os.path.exists(_legacy_yaml_path) + with open(_legacy_yaml_path, "r") as f: + data = yaml.safe_load(f) + assert "legacy_to_v1" in data + assert "legacy_prefix_map" in data + +def test_code_matches_yaml_exactly(): + # Verify the code parsed it correctly + with open(_legacy_yaml_path, "r") as f: + data = yaml.safe_load(f) + + assert _LEGACY_TO_V1 == data["legacy_to_v1"] + + yaml_prefixes = [(item["legacy_prefix"], item["v1_prefix"]) for item in data["legacy_prefix_map"]] + assert _LEGACY_PREFIX_MAP == yaml_prefixes + +def test_legacy_routes_are_covered_by_yaml(): + """ + Ensure that all legacy /ai/ routes (except metrics, ocr, and /v1) defined in code + are either directly mapped in legacy_to_v1 or covered by legacy_prefix_map. + """ + # Find all legacy routes defined in main.py or its included routers + legacy_routes = set() + for route in app.routes: + if isinstance(route, APIRoute): + path = route.path + # We are looking for legacy routes: they start with /ai/ but not /v1/ai/ + if path.startswith("/ai/") and not path.startswith("/v1/ai/"): + # Exclude metrics and ocr which are intentionally not redirected + if path not in ("/ai/metrics", "/ai/ocr", "/ai/ocr/process"): + legacy_routes.add(path) + + # Routes covered by exact match + exact_matches = set(_LEGACY_TO_V1.keys()) + + for route_path in legacy_routes: + if route_path in exact_matches: + continue + + # Check prefix match + matched_prefix = False + for legacy_prefix, _ in _LEGACY_PREFIX_MAP: + # e.g. /ai/status/{task_id} starts with /ai/status/ + # In FastAPI, the parameterized route looks like /ai/status/{task_id} + # We strip the parameter part to see if it starts with the prefix + if route_path.startswith(legacy_prefix): + matched_prefix = True + break + + assert matched_prefix, f"Legacy route {route_path} exists in code but is not covered by legacy_redirects.yaml" + +def test_no_extra_yaml_entries(): + """ + Ensure we don't have dangling entries in YAML that no longer map to any legacy route. + """ + legacy_routes = set() + for route in app.routes: + if isinstance(route, APIRoute): + path = route.path + if path.startswith("/ai/") and not path.startswith("/v1/ai/"): + if path not in ("/ai/metrics", "/ai/ocr", "/ai/ocr/process"): + legacy_routes.add(path) + + # Check that every exact match in YAML actually corresponds to a defined legacy route + for legacy_path in _LEGACY_TO_V1.keys(): + assert legacy_path in legacy_routes, f"YAML contains exact redirect for {legacy_path} but it is not defined in code" + + # For prefix matches, ensure at least one legacy route matches it + for legacy_prefix, _ in _LEGACY_PREFIX_MAP: + matches = [r for r in legacy_routes if r.startswith(legacy_prefix)] + assert len(matches) > 0, f"YAML contains prefix redirect for {legacy_prefix} but no code route matches it"