-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
244 lines (224 loc) · 10.1 KB
/
Copy pathclient.py
File metadata and controls
244 lines (224 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
import base64
from datetime import UTC, datetime, timedelta
from types import TracebackType
from typing import Any
import httpx
from app.connect.schemas import (
PartnerApiRequest,
PartnerApiResponse,
RecordDetail,
RecordSummary,
TokenIssueResponse,
WebhookRegisterResponse,
WebhookSummary,
)
class ConnectClient:
def __init__(
self,
*,
base_url: str,
client_id: str,
client_secret: str,
audience: str = "saylog",
scope: str | None = None,
provider_code: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
timeout: float = 10.0,
) -> None:
# base_url은 dou-connect 호스트 루트(예: http://localhost,
# https://connect.dou.so). dou-connect는 OAuth 토큰 엔드포인트
# (`/v1/oauth/token`, web.php)와 게이트웨이 프록시
# (`/api/{service}/{path}`, api.php)를 서로 다른 루트에 둔다.
# httpx의 RFC 3986 결합 규칙상 base.path를 보존하려면 trailing
# slash가 필요하므로 강제로 추가한다.
self._base_url = base_url.rstrip("/") + "/"
self._client_id = client_id
self._client_secret = client_secret
self._audience = audience
self._scope = scope
# tenant-required service(saylog 등)는 token 발급 시 provider_code
# (Hospital.provider_code, 요양기관번호)로 어느 병원인지 지정 필수.
# 미지정 시 dou-connect가 403 access_denied로 거부.
self._provider_code = provider_code
self._token: str | None = None
self._token_expires_at: datetime | None = None
self._http = httpx.AsyncClient(
base_url=self._base_url, transport=transport, timeout=timeout,
)
async def __aenter__(self) -> "ConnectClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
await self._http.aclose()
async def _get_token(self, force: bool = False) -> str:
now = datetime.now(UTC)
if (
not force and self._token is not None
and self._token_expires_at is not None
and now < self._token_expires_at - timedelta(seconds=60)
):
return self._token
# RFC 6749 §2.3.1 Basic 인증. 본문에 client_id/secret을 함께
# 보내면 dou-connect가 `invalid_request`로 거부하므로 둘 중 하나만
# 사용한다. 본 클라이언트는 Basic 헤더 방식을 사용한다.
creds = f"{self._client_id}:{self._client_secret}".encode()
basic = base64.b64encode(creds).decode("ascii")
data: dict[str, str] = {
"grant_type": "client_credentials",
"audience": self._audience,
}
if self._scope:
data["scope"] = self._scope
if self._provider_code:
data["provider_code"] = self._provider_code
r = await self._http.post(
"v1/oauth/token",
data=data,
headers={
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
r.raise_for_status()
parsed = TokenIssueResponse.model_validate(r.json())
self._token = parsed.access_token
self._token_expires_at = now + timedelta(seconds=parsed.expires_in)
return self._token
async def _request(
self, method: str, path: str, **kwargs: Any,
) -> httpx.Response:
# 게이트웨이 프록시 호출 전용. path는 `{service}/{path}`
# (예: `saylog/v1/partner-api`)로 받고 dou-connect의 `/api`
# 라우트 그룹에 맞춰 prefix를 prepend한다. 토큰 엔드포인트
# (`/v1/oauth/token`)는 별도 루트라 `_get_token`이 따로 처리.
gateway_path = f"api/{path}"
token = await self._get_token()
headers = dict(kwargs.pop("headers", {}))
headers["Authorization"] = f"Bearer {token}"
r = await self._http.request(method, gateway_path, headers=headers, **kwargs)
if r.status_code == 401:
token = await self._get_token(force=True)
headers["Authorization"] = f"Bearer {token}"
r = await self._http.request(
method, gateway_path, headers=headers, **kwargs,
)
return r
async def issue_token(self) -> TokenIssueResponse:
token = await self._get_token(force=True)
expires_in = 0
if self._token_expires_at is not None:
expires_in = int(
(self._token_expires_at - datetime.now(UTC)).total_seconds()
)
return TokenIssueResponse(
access_token=token,
token_type="Bearer",
expires_in=expires_in,
scope=self._scope,
)
async def register_partner_api(
self, request: PartnerApiRequest,
) -> PartnerApiResponse:
# PUT은 전체 교체(부분 갱신 없음). 응답에는 clientSecret이 없고
# endpoints.*.auth(user|service)·oauth.redirectUri가 파생값으로 온다.
# GET 결과를 그대로 PUT하면 oauth가 빠져 IdP 등록이 삭제되므로 주의.
r = await self._request(
"PUT", "saylog/v1/partner-api",
json=request.model_dump(by_alias=True, exclude_none=True),
)
r.raise_for_status()
return PartnerApiResponse.model_validate(r.json())
async def get_partner_api(self) -> PartnerApiResponse:
r = await self._request("GET", "saylog/v1/partner-api")
r.raise_for_status()
return PartnerApiResponse.model_validate(r.json())
async def start_partner_oauth(self) -> dict[str, Any]:
# dou-connect 게이트웨이를 거쳐 saylog 서비스의 partner-oauth 시작을 호출.
# 인자 없음 — saylog 측에서 토큰 claim의 partner_id로 어느 파트너 흐름인지 판단.
# 응답: {"authorizationUrl": "...", "nonce": "..."}. authorizationUrl을
# 브라우저로 열면 파트너 OAuth 서버로 redirect되고, 완료 후 dou-connect
# 게이트웨이의 콜백 경로로 돌아온다.
r = await self._request("POST", "saylog/v1/partner-oauth/start")
r.raise_for_status()
return r.json() # type: ignore[no-any-return]
async def validate_partner_api(
self,
partner_user_access_token: str | None = None,
*,
sample_employee_id: str | None = None,
sample_name: str | None = None,
) -> dict[str, Any]:
# 등록된 항목만 검증된다.
# - 사용자 토큰 모드(oauth 등록): 새록이 `Partner-Access-Token`으로 받은
# **파트너 OAuth 사용자 토큰**으로 userInfoUrl·endpoints.patients를 호출한다.
# dou-connect client-credentials 토큰과 다르다.
# - 서비스 토큰 모드(oauth 없음) / employee: 새록이 dou-connect 서비스 토큰을
# 직접 발급받아 호출하므로 헤더 대신 본문 sample.employeeId(·name)이 필요하다.
# 생략하면 해당 항목은 skipped.
headers: dict[str, str] = {}
if partner_user_access_token is not None:
headers["Partner-Access-Token"] = partner_user_access_token
sample: dict[str, str] = {}
if sample_employee_id is not None:
sample["employeeId"] = sample_employee_id
if sample_name is not None:
sample["name"] = sample_name
kwargs: dict[str, Any] = {"headers": headers}
if sample:
kwargs["json"] = {"sample": sample}
r = await self._request("POST", "saylog/v1/validate-partner-api", **kwargs)
r.raise_for_status()
return r.json() # type: ignore[no-any-return]
async def register_webhook(
self, *, url: str, event: str = "records.summarized"
) -> WebhookRegisterResponse:
# ConnectWebhookCreateRequest.event는 4값 enum(records.summarized |
# records.transcribed | records.updated | records.deleted) 단일 문자열
# (배열 아님). 등록은 이벤트 단위이므로 이벤트마다 별도로 호출하고,
# 등록 건마다 별도 secret이 발급된다. 서버 측 default가 있어 본문에서
# 생략해도 되지만, 명시적으로 보내 의도가 분명히 드러나도록 한다.
r = await self._request(
"POST", "saylog/v1/webhooks",
json={"url": url, "event": event},
)
r.raise_for_status()
return WebhookRegisterResponse.model_validate(r.json())
async def list_webhooks(self) -> list[WebhookSummary]:
r = await self._request("GET", "saylog/v1/webhooks")
r.raise_for_status()
payload = r.json()
return [WebhookSummary.model_validate(x) for x in payload["webhooks"]]
async def delete_webhook(self, webhook_id: str) -> None:
r = await self._request("DELETE", f"saylog/v1/webhooks/{webhook_id}")
r.raise_for_status()
async def list_records(
self,
*,
since: str | None = None,
employee_id: str | None = None,
patient_uid: str | None = None,
limit: int | None = None,
) -> list[RecordSummary]:
# ConnectRecordListResponse는 {"records": [...]} 래퍼 + 쿼리는
# since / employeeId / patientUid / limit(1~100, 기본 50)만 허용.
params: dict[str, str | int] = {}
if since is not None:
params["since"] = since
if employee_id is not None:
params["employeeId"] = employee_id
if patient_uid is not None:
params["patientUid"] = patient_uid
if limit is not None:
params["limit"] = limit
r = await self._request("GET", "saylog/v1/records", params=params)
r.raise_for_status()
payload = r.json()
return [RecordSummary.model_validate(x) for x in payload["records"]]
async def get_record(self, record_id: str) -> RecordDetail:
r = await self._request("GET", f"saylog/v1/records/{record_id}")
r.raise_for_status()
return RecordDetail.model_validate(r.json())