Skip to content

Commit 66b7f47

Browse files
authored
CONF: Add HttpHeader enum for fixed header values (#90)
* CONF: Add x-upstream-source header key * CONF: Add HttpHeader enum for fixed header values
1 parent 1eab29b commit 66b7f47

10 files changed

Lines changed: 136 additions & 73 deletions

File tree

src/fmu_settings_api/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from fastapi.routing import APIRoute
1111
from starlette.middleware.cors import CORSMiddleware
1212

13-
from .config import settings
13+
from .config import HttpHeader, settings
1414
from .models import Ok
1515
from .v1.main import api_v1_router
1616

@@ -65,7 +65,7 @@ def run_server( # noqa PLR0913
6565
allow_credentials=True,
6666
allow_methods=["*"],
6767
allow_headers=["*"],
68-
expose_headers=["x-upstream-source"],
68+
expose_headers=[HttpHeader.UPSTREAM_SOURCE_KEY],
6969
)
7070

7171
def signal_handler(signum: int, frame: FrameType | None) -> None:

src/fmu_settings_api/config.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import hashlib
44
import secrets
5-
from typing import Annotated, Any, Self
5+
from typing import Annotated, Any, Final, Self
66

77
from pydantic import (
88
BaseModel,
@@ -39,11 +39,24 @@ def parse_cors(v: Any) -> list[HttpUrl]:
3939
raise ValueError(f"Invalid list of origins: {v}")
4040

4141

42+
class HttpHeader:
43+
"""Contains Http header keys and values for API requests."""
44+
45+
API_TOKEN_KEY: Final[str] = "x-fmu-settings-api"
46+
UPSTREAM_SOURCE_KEY: Final[str] = "x-upstream-source"
47+
UPSTREAM_SOURCE_SMDA: Final[str] = "SMDA"
48+
WWW_AUTHENTICATE_KEY: Final[str] = "WWW-Authenticate"
49+
WWW_AUTHENTICATE_COOKIE: Final[str] = "Cookie-Auth"
50+
CONTENT_TYPE_KEY: Final[str] = "Content-Type"
51+
CONTENT_TYPE_JSON: Final[str] = "application/json"
52+
AUTHORIZATION_KEY: Final[str] = "authorization"
53+
OCP_APIM_SUBSCRIPTION_KEY: Final[str] = "Ocp-Apim-Subscription-Key"
54+
55+
4256
class APISettings(BaseModel):
4357
"""Settings used for the API."""
4458

4559
API_V1_PREFIX: str = Field(default="/api/v1", frozen=True)
46-
TOKEN_HEADER_NAME: str = Field(default="x-fmu-settings-api", frozen=True)
4760
TOKEN: str = Field(
4861
default_factory=generate_auth_token,
4962
pattern=r"^[a-fA-F0-9]{64}$",

src/fmu_settings_api/deps.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
from fmu.settings._fmu_dir import UserFMUDirectory
88
from fmu.settings._init import init_user_fmu_directory
99

10-
from fmu_settings_api.config import settings
10+
from fmu_settings_api.config import HttpHeader, settings
1111
from fmu_settings_api.session import (
1212
ProjectSession,
1313
Session,
1414
SessionNotFoundError,
1515
session_manager,
1616
)
1717

18-
api_token_header = APIKeyHeader(name=settings.TOKEN_HEADER_NAME)
18+
api_token_header = APIKeyHeader(name=HttpHeader.API_TOKEN_KEY)
1919

2020
TokenHeaderDep = Annotated[str, Security(api_token_header)]
2121

@@ -80,15 +80,19 @@ async def get_session(
8080
raise HTTPException(
8181
status_code=401,
8282
detail="No active session found",
83-
headers={"WWW-Authenticate": "Cookie-Auth"},
83+
headers={
84+
HttpHeader.WWW_AUTHENTICATE_KEY: HttpHeader.WWW_AUTHENTICATE_COOKIE
85+
},
8486
)
8587
try:
8688
return await session_manager.get_session(fmu_settings_session)
8789
except SessionNotFoundError as e:
8890
raise HTTPException(
8991
status_code=401,
9092
detail="Invalid or expired session",
91-
headers={"WWW-Authenticate": "Cookie-Auth"},
93+
headers={
94+
HttpHeader.WWW_AUTHENTICATE_KEY: HttpHeader.WWW_AUTHENTICATE_COOKIE
95+
},
9296
) from e
9397
except Exception as e:
9498
raise HTTPException(status_code=500, detail=f"Session error: {e}") from e
@@ -122,13 +126,13 @@ async def ensure_smda_session(session: Session) -> None:
122126
raise HTTPException(
123127
status_code=401,
124128
detail="User SMDA API key is not configured",
125-
headers={"x-upstream-source": "SMDA"},
129+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
126130
)
127131
if session.access_tokens.smda_api is None:
128132
raise HTTPException(
129133
status_code=401,
130134
detail="SMDA access token is not set",
131-
headers={"x-upstream-source": "SMDA"},
135+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
132136
)
133137

134138

src/fmu_settings_api/interfaces/smda_api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import httpx
77

8+
from fmu_settings_api.config import HttpHeader
9+
810

911
class SmdaRoutes:
1012
"""Contains routes used by routes in this API."""
@@ -26,9 +28,9 @@ def __init__(self, access_token: str, subscription_key: str):
2628
self._access_token = access_token
2729
self._subscription_key = subscription_key
2830
self._headers = {
29-
"Content-Type": "application/json",
30-
"authorization": f"Bearer {self._access_token}",
31-
"Ocp-Apim-Subscription-Key": self._subscription_key,
31+
HttpHeader.CONTENT_TYPE_KEY: HttpHeader.CONTENT_TYPE_JSON,
32+
HttpHeader.AUTHORIZATION_KEY: f"Bearer {self._access_token}",
33+
HttpHeader.OCP_APIM_SUBSCRIPTION_KEY: self._subscription_key,
3234
}
3335

3436
async def get(self, route: str) -> httpx.Response:

src/fmu_settings_api/v1/routes/smda/main.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
FieldItem,
1212
)
1313

14+
from fmu_settings_api.config import HttpHeader
1415
from fmu_settings_api.deps import (
1516
ProjectSmdaSessionDep,
1617
SessionDep,
@@ -33,7 +34,7 @@
3334

3435
def _add_response_headers(response: Response) -> Generator[None]:
3536
"""Adds headers specific to the /smda route."""
36-
response.headers["x-upstream-source"] = "SMDA"
37+
response.headers[HttpHeader.UPSTREAM_SOURCE_KEY] = HttpHeader.UPSTREAM_SOURCE_SMDA
3738
yield
3839

3940

@@ -70,7 +71,7 @@ async def get_health(session: SessionDep) -> Ok:
7071
raise HTTPException(
7172
status_code=401,
7273
detail="SMDA access token is not set",
73-
headers={"x-upstream-source": "SMDA"},
74+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
7475
)
7576

7677
try:
@@ -86,13 +87,13 @@ async def get_health(session: SessionDep) -> Ok:
8687
raise HTTPException(
8788
status_code=e.response.status_code,
8889
detail=f"SMDA error requesting {e.request.url}",
89-
headers={"x-upstream-source": "SMDA"},
90+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
9091
) from e
9192
except Exception as e:
9293
raise HTTPException(
9394
status_code=500,
9495
detail=str(e),
95-
headers={"x-upstream-source": "SMDA"},
96+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
9697
) from e
9798

9899

@@ -125,7 +126,7 @@ async def post_field(session: SessionDep, field: SmdaField) -> SmdaFieldSearchRe
125126
raise HTTPException(
126127
status_code=401,
127128
detail="SMDA access token is not set",
128-
headers={"x-upstream-source": "SMDA"},
129+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
129130
)
130131

131132
try:
@@ -143,25 +144,25 @@ async def post_field(session: SessionDep, field: SmdaField) -> SmdaFieldSearchRe
143144
raise HTTPException(
144145
status_code=e.response.status_code,
145146
detail=f"SMDA error requesting {e.request.url!r}",
146-
headers={"x-upstream-source": "SMDA"},
147+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
147148
) from e
148149
except KeyError as e:
149150
raise HTTPException(
150151
status_code=500,
151152
detail="Malformed response from SMDA: no 'data' field present",
152-
headers={"x-upstream-source": "SMDA"},
153+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
153154
) from e
154155
except TimeoutError as e:
155156
raise HTTPException(
156157
status_code=503,
157158
detail="SMDA API request timed out. Please try again.",
158-
headers={"x-upstream-source": "SMDA"},
159+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
159160
) from e
160161
except Exception as e:
161162
raise HTTPException(
162163
status_code=500,
163164
detail=str(e),
164-
headers={"x-upstream-source": "SMDA"},
165+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
165166
) from e
166167

167168

@@ -215,7 +216,7 @@ async def post_masterdata(
215216
raise HTTPException(
216217
status_code=401,
217218
detail="SMDA access token is not set",
218-
headers={"x-upstream-source": "SMDA"},
219+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
219220
)
220221

221222
# Sorted for tests as sets don't guarantee order
@@ -243,7 +244,9 @@ async def post_masterdata(
243244
raise HTTPException(
244245
status_code=404,
245246
detail=f"No fields found for identifiers: {unique_field_identifiers}",
246-
headers={"x-upstream-source": "SMDA"},
247+
headers={
248+
HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA
249+
},
247250
)
248251

249252
field_items = [FieldItem(**field) for field in field_results]
@@ -277,7 +280,9 @@ async def post_masterdata(
277280
raise HTTPException(
278281
status_code=404,
279282
detail="Projected field coordinate system not found",
280-
headers={"x-upstream-source": "SMDA"},
283+
headers={
284+
HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA
285+
},
281286
)
282287

283288
return SmdaMasterdataResult(
@@ -294,23 +299,23 @@ async def post_masterdata(
294299
raise HTTPException(
295300
status_code=e.response.status_code,
296301
detail=f"SMDA error requesting {e.request.url}",
297-
headers={"x-upstream-source": "SMDA"},
302+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
298303
) from e
299304
except KeyError as e:
300305
raise HTTPException(
301306
status_code=500,
302307
detail="Malformed response from SMDA: {e}",
303-
headers={"x-upstream-source": "SMDA"},
308+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
304309
) from e
305310
except Exception as e:
306311
raise HTTPException(
307312
status_code=500,
308313
detail=str(e),
309-
headers={"x-upstream-source": "SMDA"},
314+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
310315
) from e
311316
except TimeoutError as e:
312317
raise HTTPException(
313318
status_code=503,
314319
detail="SMDA API request timed out. Please try again.",
315-
headers={"x-upstream-source": "SMDA"},
320+
headers={HttpHeader.UPSTREAM_SOURCE_KEY: HttpHeader.UPSTREAM_SOURCE_SMDA},
316321
) from e

tests/test_interfaces/test_smda_api.py

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

66
import pytest
77

8+
from fmu_settings_api.config import HttpHeader
89
from fmu_settings_api.interfaces.smda_api import SmdaAPI, SmdaRoutes
910

1011

@@ -40,9 +41,9 @@ async def test_smda_get(mock_httpx_get: MagicMock) -> None:
4041
mock_httpx_get.assert_called_with(
4142
f"{SmdaRoutes.BASE_URL}/{SmdaRoutes.HEALTH}",
4243
headers={
43-
"Content-Type": "application/json",
44-
"authorization": "Bearer token",
45-
"Ocp-Apim-Subscription-Key": "key",
44+
HttpHeader.CONTENT_TYPE_KEY: HttpHeader.CONTENT_TYPE_JSON,
45+
HttpHeader.AUTHORIZATION_KEY: "Bearer token",
46+
HttpHeader.OCP_APIM_SUBSCRIPTION_KEY: "key",
4647
},
4748
)
4849
res.raise_for_status.assert_called_once() # type: ignore
@@ -56,9 +57,9 @@ async def test_smda_post_with_json(mock_httpx_post: MagicMock) -> None:
5657
mock_httpx_post.assert_called_with(
5758
f"{SmdaRoutes.BASE_URL}/{SmdaRoutes.HEALTH}",
5859
headers={
59-
"Content-Type": "application/json",
60-
"authorization": "Bearer token",
61-
"Ocp-Apim-Subscription-Key": "key",
60+
HttpHeader.CONTENT_TYPE_KEY: HttpHeader.CONTENT_TYPE_JSON,
61+
HttpHeader.AUTHORIZATION_KEY: "Bearer token",
62+
HttpHeader.OCP_APIM_SUBSCRIPTION_KEY: "key",
6263
},
6364
json={"a": "b"},
6465
)
@@ -73,9 +74,9 @@ async def test_smda_post_without_json(mock_httpx_post: MagicMock) -> None:
7374
mock_httpx_post.assert_called_with(
7475
f"{SmdaRoutes.BASE_URL}/{SmdaRoutes.HEALTH}",
7576
headers={
76-
"Content-Type": "application/json",
77-
"authorization": "Bearer token",
78-
"Ocp-Apim-Subscription-Key": "key",
77+
HttpHeader.CONTENT_TYPE_KEY: HttpHeader.CONTENT_TYPE_JSON,
78+
HttpHeader.AUTHORIZATION_KEY: "Bearer token",
79+
HttpHeader.OCP_APIM_SUBSCRIPTION_KEY: "key",
7980
},
8081
json=None,
8182
)

tests/test_v1/test_health.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi.testclient import TestClient
55

66
from fmu_settings_api.__main__ import app
7-
from fmu_settings_api.config import settings
7+
from fmu_settings_api.config import HttpHeader
88
from fmu_settings_api.models import Ok
99

1010
client = TestClient(app)
@@ -22,14 +22,14 @@ def test_health_check_no_session() -> None:
2222
def test_health_check_no_session_bad_token() -> None:
2323
"""Test the health check endpoint with an invalid token but no session."""
2424
token = "no" * 32
25-
response = client.get(ROUTE, headers={settings.TOKEN_HEADER_NAME: token})
25+
response = client.get(ROUTE, headers={HttpHeader.API_TOKEN_KEY: token})
2626
assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.json()
2727
assert response.json() == {"detail": "No active session found"}
2828

2929

3030
def test_health_check_no_session_valid_token(mock_token: str) -> None:
3131
"""Test the health check endpoint with a valid token but no session."""
32-
response = client.get(ROUTE, headers={settings.TOKEN_HEADER_NAME: mock_token})
32+
response = client.get(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token})
3333
assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.json()
3434
assert response.json() == {"detail": "No active session found"}
3535

@@ -47,9 +47,7 @@ def test_health_check_no_session_valid_session_invalid_token(
4747
) -> None:
4848
"""Test the health check endpoint with a valid session and invalid token."""
4949
token = "no" * 32
50-
response = client_with_session.get(
51-
ROUTE, headers={settings.TOKEN_HEADER_NAME: token}
52-
)
50+
response = client_with_session.get(ROUTE, headers={HttpHeader.API_TOKEN_KEY: token})
5351
assert response.status_code == status.HTTP_200_OK, response.json()
5452
assert response.json() == {"status": "ok"}
5553
assert Ok() == Ok.model_validate(response.json())
@@ -60,7 +58,7 @@ def test_health_check_no_session_valid_session_valid_token(
6058
) -> None:
6159
"""Test the health check endpoint with a valid session and valid token."""
6260
response = client_with_session.get(
63-
ROUTE, headers={settings.TOKEN_HEADER_NAME: mock_token}
61+
ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token}
6462
)
6563
assert response.status_code == status.HTTP_200_OK, response.json()
6664
assert response.json() == {"status": "ok"}

tests/test_v1/test_project.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pytest import MonkeyPatch
1313

1414
from fmu_settings_api.__main__ import app
15-
from fmu_settings_api.config import settings
15+
from fmu_settings_api.config import HttpHeader, settings
1616
from fmu_settings_api.models.project import FMUProject
1717
from fmu_settings_api.session import ProjectSession, Session
1818

@@ -30,11 +30,11 @@ def test_get_project_does_not_care_about_token(mock_token: str) -> None:
3030
assert response.status_code == status.HTTP_401_UNAUTHORIZED
3131
assert response.json() == {"detail": "No active session found"}
3232

33-
response = client.get(ROUTE, headers={settings.TOKEN_HEADER_NAME: mock_token})
33+
response = client.get(ROUTE, headers={HttpHeader.API_TOKEN_KEY: mock_token})
3434
assert response.status_code == status.HTTP_401_UNAUTHORIZED
3535
assert response.json() == {"detail": "No active session found"}
3636

37-
response = client.get(ROUTE, headers={settings.TOKEN_HEADER_NAME: "no" * 32})
37+
response = client.get(ROUTE, headers={HttpHeader.API_TOKEN_KEY: "no" * 32})
3838
assert response.status_code == status.HTTP_401_UNAUTHORIZED
3939
assert response.json() == {"detail": "No active session found"}
4040

0 commit comments

Comments
 (0)