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
11 changes: 11 additions & 0 deletions app/ai-service/config/legacy_redirects.yaml
Original file line number Diff line number Diff line change
@@ -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/"
52 changes: 42 additions & 10 deletions app/ai-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]


Expand Down
4 changes: 2 additions & 2 deletions app/ai-service/requirements-prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/ai-service/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions app/ai-service/tests/test_legacy_redirects.py
Original file line number Diff line number Diff line change
@@ -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"
Loading