Skip to content

Commit 81b2f6d

Browse files
committed
Refactored webserver client
Created a thin wrapper for sessions with webserver Simplifies and unifies route handlers logic
1 parent 5ca6854 commit 81b2f6d

File tree

4 files changed

+106
-52
lines changed

4 files changed

+106
-52
lines changed

services/api-server/src/simcore_service_api_server/api/dependencies/webserver.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
from typing import Dict, Optional
44

55
from cryptography.fernet import Fernet
6-
from fastapi import Depends, HTTPException, status
6+
from fastapi import Depends, FastAPI, HTTPException, status
77
from fastapi.requests import Request
8-
from httpx import AsyncClient
98

109
from ...core.settings import AppSettings, WebServerSettings
10+
from ...services.webserver import AuthSession
1111
from .authentication import get_active_user_email
1212

1313
UNAVAILBLE_MSG = "backend service is disabled or unreachable"
1414

1515

16+
def _get_app(request: Request) -> FastAPI:
17+
return request.app
18+
19+
1620
def _get_settings(request: Request) -> WebServerSettings:
1721
app_settings: AppSettings = request.app.state.settings
1822
return app_settings.webserver
@@ -22,13 +26,6 @@ def _get_encrypt(request: Request) -> Optional[Fernet]:
2226
return getattr(request.app.state, "webserver_fernet", None)
2327

2428

25-
def get_webserver_client(request: Request) -> AsyncClient:
26-
client = getattr(request.app.state, "webserver_client", None)
27-
if not client:
28-
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=UNAVAILBLE_MSG)
29-
return client
30-
31-
3229
def get_session_cookie(
3330
identity: str = Depends(get_active_user_email),
3431
settings: WebServerSettings = Depends(_get_settings),
@@ -53,3 +50,14 @@ def get_session_cookie(
5350
encrypted_cookie_data = fernet.encrypt(cookie_data).decode("utf-8")
5451

5552
return {cookie_name: encrypted_cookie_data}
53+
54+
55+
def get_webserver_session(
56+
app: FastAPI = Depends(_get_app),
57+
session_cookies: Dict = Depends(get_session_cookie),
58+
) -> AuthSession:
59+
"""
60+
Lifetime of AuthSession wrapper is one request because it needs different session cookies
61+
Lifetime of embedded client is attached to the app lifetime
62+
"""
63+
return AuthSession.create(app, session_cookies)
Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,48 @@
11
import logging
2-
from typing import Dict
32

43
from fastapi import APIRouter, Depends, HTTPException, Security
5-
from httpx import AsyncClient, Response, StatusCode
6-
7-
# SEE: https://www.python-httpx.org/async/
8-
# TODO: path mapping and operation
9-
# TODO: if fails, raise for status and translates to service unavailable if fails
10-
#
114
from pydantic import ValidationError
125
from starlette import status
136

147
from ...models.schemas.profiles import Profile, ProfileUpdate
15-
from ..dependencies.webserver import get_session_cookie, get_webserver_client
8+
from ..dependencies.webserver import AuthSession, get_webserver_session
169

1710
logger = logging.getLogger(__name__)
1811

1912

2013
router = APIRouter()
14+
# SEE: https://www.python-httpx.org/async/
15+
# TODO: path mapping and operation
2116

2217

2318
@router.get("", response_model=Profile)
2419
async def get_my_profile(
25-
client: AsyncClient = Depends(get_webserver_client),
26-
session_cookies: Dict = Depends(get_session_cookie),
20+
client: AuthSession = Depends(get_webserver_session),
2721
) -> Profile:
28-
resp = await client.get("/v0/me", cookies=session_cookies)
29-
30-
if resp.status_code == status.HTTP_200_OK:
31-
data = resp.json()["data"]
32-
try:
33-
# FIXME: temporary patch until web-API is reviewed
34-
data["role"] = data["role"].upper()
35-
profile = Profile.parse_obj(data)
36-
return profile
37-
except ValidationError:
38-
logger.exception("webserver response invalid")
39-
raise
40-
41-
elif StatusCode.is_server_error(resp.status_code):
42-
logger.error("webserver failed :%s", resp.reason_phrase)
22+
data = await client.get("/me")
23+
24+
# FIXME: temporary patch until web-API is reviewed
25+
data["role"] = data["role"].upper()
26+
try:
27+
profile = Profile.parse_obj(data)
28+
except ValidationError:
29+
logger.exception("webserver invalid response")
4330
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE)
4431

45-
raise HTTPException(resp.status_code, resp.reason_phrase)
32+
return profile
4633

4734

4835
@router.put("", response_model=Profile)
4936
async def update_my_profile(
5037
profile_update: ProfileUpdate,
51-
client: AsyncClient = Depends(get_webserver_client),
52-
session_cookies: Dict = Security(get_session_cookie, scopes=["write"]),
38+
client: AuthSession = Security(get_webserver_session, scopes=["write"]),
5339
) -> Profile:
5440
# FIXME: replace by patch
5541
# TODO: improve. from patch -> put, we need to ensure it has a default in place
5642
profile_update.first_name = profile_update.first_name or ""
5743
profile_update.last_name = profile_update.last_name or ""
58-
resp: Response = await client.put(
59-
"/v0/me", json=profile_update.dict(), cookies=session_cookies
60-
)
6144

62-
if StatusCode.is_error(resp.status_code):
63-
logger.error("webserver failed: %s", resp.reason_phrase)
64-
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE)
45+
await client.put("/me", body=profile_update.dict())
6546

66-
profile = await get_my_profile(client, session_cookies)
47+
profile = await get_my_profile(client)
6748
return profile

services/api-server/src/simcore_service_api_server/services/remote_debug.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def setup_remote_debugging(force_enabled=False, *, boot_mode=None):
3333

3434
logger.info("Remote debugging enabled: listening port %s", REMOTE_DEBUG_PORT)
3535
else:
36-
logger.debug("Booting without remote debugging since SC_BOOT_MODE=%s", boot_mode)
36+
logger.debug(
37+
"Booting without remote debugging since SC_BOOT_MODE=%s", boot_mode
38+
)
3739

3840

3941
__all__ = ["setup_remote_debugging"]

services/api-server/src/simcore_service_api_server/services/webserver.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import base64
2+
import json
23
import logging
4+
from typing import Dict, Optional
35

6+
import attr
47
from cryptography import fernet
5-
from fastapi import FastAPI
6-
from httpx import AsyncClient
8+
from fastapi import FastAPI, HTTPException
9+
from httpx import AsyncClient, Response, StatusCode
10+
from starlette import status
711

812
from ..core.settings import WebServerSettings
913

1014
logger = logging.getLogger(__name__)
1115

1216

13-
# TODO: create client setup with all info inside
14-
15-
1617
def _get_secret_key(settings: WebServerSettings):
1718
secret_key_bytes = settings.session_secret_key.get_secret_value().encode("utf-8")
1819
while len(secret_key_bytes) < 32:
@@ -53,5 +54,67 @@ async def close_webserver(app: FastAPI) -> None:
5354
logger.debug("Webserver closed successfully")
5455

5556

56-
def get_webserver_client(app: FastAPI) -> AsyncClient:
57-
return app.state.webserver_client
57+
@attr.s(auto_attribs=True)
58+
class AuthSession:
59+
"""
60+
- wrapper around thin-client to simplify webserver's API
61+
- sets endspoint upon construction
62+
- MIME type: application/json
63+
- processes responses, returning data or raising formatted HTTP exception
64+
- The lifetime of an AuthSession is ONE request.
65+
66+
SEE services/api-server/src/simcore_service_api_server/api/dependencies/webserver.py
67+
"""
68+
69+
client: AsyncClient # Its lifetime is attached to app
70+
vtag: str
71+
session_cookies: Dict = None
72+
73+
@classmethod
74+
def create(cls, app: FastAPI, session_cookies: Dict):
75+
return cls(
76+
client=app.state.webserver_client,
77+
vtag=app.state.settings.webserver.vtag,
78+
session_cookies=session_cookies,
79+
)
80+
81+
def _url(self, path: str) -> str:
82+
return f"/{self.vtag}/{path.ltrip('/')}"
83+
84+
@classmethod
85+
def _process(cls, resp: Response) -> Optional[Dict]:
86+
# enveloped answer
87+
data, error = None, None
88+
try:
89+
body = resp.json()
90+
data, error = body["data"], body["error"]
91+
except (json.JSONDecodeError, KeyError):
92+
logger.warning("Failed to unenvelop webserver response", exc_info=True)
93+
94+
if StatusCode.is_server_error(resp.status_code):
95+
logger.error(
96+
"webserver error %d [%s]: %s",
97+
resp.status_code,
98+
resp.reason_phrase,
99+
error,
100+
)
101+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE)
102+
103+
elif StatusCode.is_client_error(resp.status_code):
104+
msg = error or resp.reason_phrase
105+
raise HTTPException(resp.status_code, detail=msg)
106+
107+
return data
108+
109+
# OPERATIONS
110+
# TODO: automate conversion
111+
112+
async def get(self, path: str) -> Optional[Dict]:
113+
url = self._url(path)
114+
resp = await self.client.get(url, cookies=self.session_cookies)
115+
return self._process(resp)
116+
117+
async def put(self, path: str, body: Dict) -> Optional[Dict]:
118+
url = self._url(path)
119+
resp = await self.client.put(url, json=body, cookies=self.session_cookies)
120+
return self._process(resp)

0 commit comments

Comments
 (0)