Skip to content

Commit 3a6beb5

Browse files
authored
Add setup for IAP API connections (#49)
1 parent b21495f commit 3a6beb5

File tree

15 files changed

+815
-374
lines changed

15 files changed

+815
-374
lines changed

eq_cir_proxy_service/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
exception_404_missing_instrument_id,
1313
exception_422_invalid_instrument_id,
1414
)
15-
from eq_cir_proxy_service.routers import instrument_router
15+
from eq_cir_proxy_service.routers import instrument
1616

1717
# Load .env file
1818
load_dotenv(".env")
@@ -57,4 +57,4 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException)
5757
)
5858

5959

60-
app.include_router(instrument_router.router)
60+
app.include_router(instrument.router)

eq_cir_proxy_service/routers/instrument_router.py renamed to eq_cir_proxy_service/routers/instrument.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88
from eq_cir_proxy_service.exceptions import exception_messages
99
from eq_cir_proxy_service.services.instrument import (
10-
instrument_conversion_service,
11-
instrument_retrieval_service,
10+
conversion,
11+
retrieval,
1212
)
13-
from eq_cir_proxy_service.services.validators.request_validator import (
13+
from eq_cir_proxy_service.services.validators.request import (
1414
validate_version,
1515
)
1616
from eq_cir_proxy_service.types.custom_types import Instrument
@@ -35,9 +35,9 @@ async def get_instrument_by_uuid(
3535
validate_version(version)
3636
target_version = version
3737

38-
instrument = await instrument_retrieval_service.retrieve_instrument(instrument_id)
38+
instrument = await retrieval.retrieve_instrument(instrument_id)
3939

40-
return await instrument_conversion_service.convert_instrument(instrument, target_version)
40+
return await conversion.convert_instrument(instrument, target_version)
4141

4242
except HTTPException:
4343
raise # re-raise so FastAPI handles it properly
Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import os
44

55
from fastapi import HTTPException, status
6-
from httpx import AsyncClient, RequestError
6+
from httpx import RequestError
77
from semver import Version
88
from structlog import get_logger
99

1010
from eq_cir_proxy_service.exceptions import exception_messages
1111
from eq_cir_proxy_service.types.custom_types import Instrument
12+
from eq_cir_proxy_service.utils.iap import get_api_client
1213

1314
logger = get_logger()
1415

@@ -59,18 +60,8 @@ async def convert_instrument(instrument: Instrument, target_version: str) -> Ins
5960
target_version=target_version,
6061
)
6162

62-
converter_service_base_url = os.getenv("CONVERTER_SERVICE_API_BASE_URL")
6363
converter_service_endpoint = os.getenv("CONVERTER_SERVICE_CONVERT_CI_ENDPOINT", "/schema")
6464

65-
if not converter_service_base_url:
66-
logger.error("CONVERTER_SERVICE_API_BASE_URL is not configured.")
67-
raise HTTPException(
68-
status_code=500,
69-
detail={
70-
"status": "error",
71-
"message": "CONVERTER_SERVICE_API_BASE_URL configuration is missing.",
72-
},
73-
)
7465
if not converter_service_endpoint:
7566
logger.error("CONVERTER_SERVICE_CONVERT_CI_ENDPOINT is not configured.")
7667
raise HTTPException(
@@ -81,23 +72,25 @@ async def convert_instrument(instrument: Instrument, target_version: str) -> Ins
8172
},
8273
)
8374

84-
url = f"{converter_service_base_url}{converter_service_endpoint}"
85-
try:
86-
async with AsyncClient() as client:
87-
response = await client.post(
88-
url,
75+
async with get_api_client(
76+
url_env="CONVERTER_SERVICE_API_BASE_URL",
77+
iap_env="CONVERTER_SERVICE_IAP_CLIENT_ID",
78+
) as converter_service_api_client:
79+
try:
80+
response = await converter_service_api_client.post(
81+
converter_service_endpoint,
8982
json={"instrument": instrument},
9083
params={"current_version": current_version, "target_version": target_version},
9184
)
92-
except RequestError as e:
93-
logger.exception("Error occurred while converting instrument: ", error=e)
94-
raise HTTPException(
95-
status_code=500,
96-
detail={
97-
"status": "error",
98-
"message": "Error connecting to Converter Service.",
99-
},
100-
) from e
85+
except RequestError as e:
86+
logger.exception("Error occurred while converting instrument.", error=e)
87+
raise HTTPException(
88+
status_code=500,
89+
detail={
90+
"status": "error",
91+
"message": "Error connecting to Converter Service.",
92+
},
93+
) from e
10194

10295
instrument_data: Instrument = response.json()
10396
return instrument_data
Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
from uuid import UUID
55

66
from fastapi import HTTPException
7-
from httpx import AsyncClient, RequestError
7+
from httpx import RequestError
88
from structlog import get_logger
99

1010
from eq_cir_proxy_service.exceptions.exception_messages import (
1111
EXCEPTION_404_INSTRUMENT_NOT_FOUND,
1212
EXCEPTION_500_INSTRUMENT_PROCESSING,
1313
)
1414
from eq_cir_proxy_service.types.custom_types import Instrument
15+
from eq_cir_proxy_service.utils.iap import get_api_client
1516

1617
logger = get_logger()
1718

@@ -27,18 +28,8 @@ async def retrieve_instrument(instrument_id: UUID) -> Instrument:
2728
"""
2829
logger.debug("Retrieving instrument from CIR...", instrument_id=instrument_id)
2930

30-
cir_base_url = os.getenv("CIR_API_BASE_URL")
3131
cir_endpoint = os.getenv("CIR_RETRIEVE_CI_ENDPOINT", "/v2/retrieve_collection_instrument")
3232

33-
if not cir_base_url:
34-
logger.error("CIR_API_BASE_URL is not configured.")
35-
raise HTTPException(
36-
status_code=500,
37-
detail={
38-
"status": "error",
39-
"message": "CIR_API_BASE_URL configuration is missing.",
40-
},
41-
)
4233
if not cir_endpoint:
4334
logger.error("CIR_RETRIEVE_CI_ENDPOINT is not configured.")
4435
raise HTTPException(
@@ -49,19 +40,21 @@ async def retrieve_instrument(instrument_id: UUID) -> Instrument:
4940
},
5041
)
5142

52-
url = f"{cir_base_url}{cir_endpoint}"
53-
try:
54-
async with AsyncClient() as client:
55-
response = await client.get(url, params={"guid": str(instrument_id)})
56-
except RequestError as e:
57-
logger.exception("Error occurred while retrieving instrument.", error=e)
58-
raise HTTPException(
59-
status_code=500,
60-
detail={
61-
"status": "error",
62-
"message": "Error connecting to CIR service.",
63-
},
64-
) from e
43+
async with get_api_client(
44+
url_env="CIR_API_BASE_URL",
45+
iap_env="CIR_IAP_CLIENT_ID",
46+
) as cir_api_client:
47+
try:
48+
response = await cir_api_client.get(cir_endpoint, params={"guid": str(instrument_id)})
49+
except RequestError as e:
50+
logger.exception("Error occurred while retrieving instrument.", error=e)
51+
raise HTTPException(
52+
status_code=500,
53+
detail={
54+
"status": "error",
55+
"message": "Error connecting to CIR service.",
56+
},
57+
) from e
6558

6659
if response.status_code == 200:
6760
logger.info("Instrument retrieved successfully.", instrument_id=instrument_id)

eq_cir_proxy_service/utils/__init__.py

Whitespace-only changes.

eq_cir_proxy_service/utils/iap.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Utility functions for handling IAP authentication and HTTP clients."""
2+
3+
import os
4+
from collections.abc import AsyncIterator
5+
from contextlib import asynccontextmanager
6+
7+
import google.oauth2.id_token
8+
from google.auth.transport import requests
9+
from httpx import AsyncClient
10+
from structlog import get_logger
11+
12+
logger = get_logger()
13+
14+
15+
def get_iap_token(audience: str) -> str:
16+
"""Fetch an ID token for the IAP-secured resource (blocking)."""
17+
token: str = google.oauth2.id_token.fetch_id_token(requests.Request(), audience) # type: ignore[no-untyped-call]
18+
if token is None:
19+
logger.error("Failed to fetch IAP token", audience=audience)
20+
error_message = f"Failed to fetch IAP token for audience {audience}"
21+
raise RuntimeError(error_message)
22+
return token
23+
24+
25+
@asynccontextmanager
26+
async def get_api_client(*, url_env: str, iap_env: str) -> AsyncIterator[AsyncClient]:
27+
"""Context-managed httpx.AsyncClient that switches between IAP and non-IAP connections.
28+
29+
Args:
30+
url_env (str): Environment variable holding the base URL of the API.
31+
iap_env (str): Environment variable holding the IAP client ID of the API.
32+
33+
Raises:
34+
RuntimeError: If the base URL environment variable is missing or empty.
35+
36+
Yields:
37+
httpx.AsyncClient: An httpx.AsyncClient instance.
38+
"""
39+
base_url = os.getenv(url_env)
40+
audience = os.getenv(iap_env)
41+
42+
if not base_url:
43+
logger.error("Missing or empty environment variable for GCP base URL", var=url_env)
44+
base_url_error = f"Missing or empty environment variable: {url_env}"
45+
raise RuntimeError(base_url_error)
46+
47+
if audience:
48+
logger.info("Using GCP API client", url_env=url_env, iap_env=iap_env)
49+
client = AsyncClient(
50+
base_url=base_url,
51+
headers={"Authorization": f"Bearer {get_iap_token(audience)}"},
52+
)
53+
else:
54+
logger.info("No IAP client ID set. Using local API client.")
55+
client = AsyncClient(base_url=base_url)
56+
57+
yield client
58+
await client.aclose()

0 commit comments

Comments
 (0)