neris-api-client 1.0.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: neris_api_client
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: NERIS API Client
5
- Author-email: Ben Coleman <ben.coleman@ul.org>
5
+ Author-email: Ben Coleman <ben.coleman@ul.org>, Tommy Messbauer <thomas.messbauer@ul.org>
6
6
  Project-URL: Homepage, https://github.com/ulfsri/neris-api-client
7
7
  Project-URL: Issues, https://neris.atlassian.net/servicedesk/customer/portal/3/group/3/create/10027
8
8
  Classifier: Programming Language :: Python :: 3
@@ -11,11 +11,9 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: boto3
15
- Requires-Dist: PyJWT
16
14
  Requires-Dist: requests
17
15
  Requires-Dist: pydantic[email]>=2
18
- Requires-Dist: typing_extensions
16
+ Requires-Dist: python-dotenv
19
17
 
20
18
  # NERIS API Client
21
19
 
@@ -4,9 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "neris_api_client"
7
- version = "1.0.1"
7
+ version = "1.2.0"
8
8
  authors = [
9
9
  { name="Ben Coleman", email="ben.coleman@ul.org" },
10
+ { name="Tommy Messbauer", email="thomas.messbauer@ul.org" },
10
11
  ]
11
12
  description = "NERIS API Client"
12
13
  readme = "README.md"
@@ -17,11 +18,9 @@ classifiers = [
17
18
  "Operating System :: OS Independent",
18
19
  ]
19
20
  dependencies = [
20
- "boto3",
21
- "PyJWT",
22
21
  "requests",
23
22
  "pydantic[email]>=2",
24
- "typing_extensions",
23
+ "python-dotenv",
25
24
  ]
26
25
 
27
26
  [project.urls]
@@ -1 +1,2 @@
1
1
  from .client import *
2
+ from .config import *
@@ -0,0 +1,326 @@
1
+ import base64
2
+ from http import HTTPStatus
3
+ import json
4
+ from uuid import UUID
5
+ from typing import List, Optional, Dict, Any
6
+ from datetime import datetime, timedelta
7
+
8
+ import requests
9
+ from pydantic import BaseModel
10
+
11
+ from .config import Config, GrantType, TokenSet
12
+
13
+ from .models import (
14
+ IncidentPayload,
15
+ PatchUnitPayload,
16
+ CreateUserPayload,
17
+ DepartmentPayload,
18
+ UpdateUserPayload,
19
+ PatchStationPayload,
20
+ PatchDepartmentPayload,
21
+ CreateDepartmentPayload,
22
+ PatchIncidentAction,
23
+ TypeIncidentStatusValue,
24
+ )
25
+
26
+ __all__ = ("NerisApiClient",)
27
+
28
+
29
+ class Encoder(json.JSONEncoder):
30
+ def default(self, obj):
31
+ if isinstance(obj, requests.structures.CaseInsensitiveDict):
32
+ return {k: v for k, v in obj.items()}
33
+
34
+ return super().default(obj)
35
+
36
+
37
+ class _NerisApiClient:
38
+ config: Config
39
+ tokens: TokenSet | None = TokenSet(access_token="", refresh_token="", expires_at=datetime.min)
40
+ _session: requests.Session = requests.Session()
41
+
42
+ def __init__(self, config: Config | None = None):
43
+ if config is None:
44
+ config = Config() # by default it loads from env
45
+
46
+ match config.grant_type:
47
+ case GrantType.CLIENT_CREDENTIALS:
48
+ assert config.client_id is not None
49
+ assert config.client_secret is not None
50
+ case GrantType.PASSWORD:
51
+ assert config.username is not None
52
+ assert config.password is not None
53
+ case _:
54
+ raise Exception("Bad grant type. Must be \"password\" or \"client_credentials\"")
55
+
56
+ self.config = config
57
+
58
+ def _update_auth(self) -> None:
59
+ res: requests.Response | None = None
60
+ token_url: str = f"{self.config.base_url}/token"
61
+
62
+ if self.tokens.expires_at <= datetime.now() and not self.tokens.refresh_token:
63
+ match self.config.grant_type:
64
+ case GrantType.PASSWORD:
65
+ res = self._session.post(
66
+ token_url,
67
+ headers={},
68
+ json={
69
+ "grant_type": GrantType.PASSWORD,
70
+ "username": self.config.username,
71
+ "password": self.config.password,
72
+ },
73
+ )
74
+
75
+ case GrantType.CLIENT_CREDENTIALS:
76
+ client_creds = base64.b64encode(f"{self.config.client_id}:{self.config.client_secret}".encode("utf-8")).decode("utf-8")
77
+
78
+ res = self._session.post(
79
+ token_url,
80
+ headers={"Authorization": f"Basic {client_creds}"},
81
+ json={"grant_type": GrantType.CLIENT_CREDENTIALS},
82
+ )
83
+
84
+ elif self.tokens.expires_at <= datetime.now():
85
+ match self.config.grant_type:
86
+ case GrantType.PASSWORD:
87
+ res = self._session.post(
88
+ token_url,
89
+ # No basic auth needed for Cognito refresh tokens
90
+ headers={},
91
+ json={
92
+ "grant_type": "refresh_token",
93
+ "refresh_token": self.tokens.refresh_token,
94
+ },
95
+ )
96
+
97
+ case GrantType.CLIENT_CREDENTIALS:
98
+ res = self._session.post(
99
+ token_url,
100
+ headers={"Authorization": f"Basic {client_creds}"},
101
+ json={
102
+ "grant_type": "refresh_token",
103
+ "refresh_token": self.tokens.refresh_token,
104
+ },
105
+ )
106
+
107
+ if not res:
108
+ return
109
+
110
+ while True:
111
+ if self.config.debug:
112
+ print(
113
+ json.dumps(
114
+ {
115
+ "token_response": {
116
+ "status_code": res.status_code,
117
+ "headers": res.headers,
118
+ "content": res.text,
119
+ }
120
+ },
121
+ indent=4,
122
+ cls=Encoder,
123
+ ),
124
+ )
125
+
126
+ got: dict = res.json()
127
+
128
+ # Successfully generated tokens
129
+ if res.status_code == HTTPStatus.OK:
130
+ self.tokens = TokenSet(
131
+ access_token=got["access_token"],
132
+ refresh_token=got["refresh_token"],
133
+ expires_at=datetime.now() + timedelta(seconds=got["expires_in"]),
134
+ )
135
+
136
+ break
137
+
138
+ # Respond to MFA challenge
139
+ elif res.status_code == HTTPStatus.ACCEPTED:
140
+ code: str = input(f"Provide MFA code for {got['challenge_name']}: ")
141
+
142
+ res = self._session.post(
143
+ token_url,
144
+ headers={},
145
+ json={
146
+ "grant_type": got["challenge_name"],
147
+ "username": self.config.username,
148
+ "session": got["session"],
149
+ got["challenge_name"]: code,
150
+ }
151
+ )
152
+
153
+ def _call(
154
+ self,
155
+ method: str,
156
+ path: str,
157
+ data: Optional[str | Dict[str, Any]] = None,
158
+ params: Optional[Dict[str, Any]] = None,
159
+ model: Optional[BaseModel] = None,
160
+ ):
161
+ self._update_auth()
162
+ self._session.headers.update({"Authorization": f"Bearer {self.tokens.access_token}"})
163
+
164
+ if model:
165
+ if isinstance(data, str):
166
+ data = model.model_validate_json(data).model_dump(mode="json", by_alias=True)
167
+ if isinstance(data, dict):
168
+ data = model.model_validate(data).model_dump(mode="json", by_alias=True)
169
+
170
+
171
+ res = getattr(self._session, method)(f"{self.config.base_url}{path}", json=data, params=params)
172
+
173
+ if self.config.debug:
174
+ print(
175
+ json.dumps(
176
+ {
177
+ "request": {
178
+ "url": f"{self.config.base_url}{path}",
179
+ "body": data,
180
+ "headers": self._session.headers,
181
+ "params": params,
182
+ },
183
+
184
+ "response": {
185
+ "status_code": res.status_code,
186
+ "headers": res.headers,
187
+ "content": res.text,
188
+ }
189
+ },
190
+ indent=4,
191
+ cls=Encoder,
192
+ ),
193
+ )
194
+
195
+ try:
196
+ res.raise_for_status()
197
+ except requests.exceptions.HTTPError as e:
198
+ try:
199
+ e.args = e.args + (e.response.json(),)
200
+ except requests.exceptions.JSONDecodeError as f:
201
+ e.args = e.args + (e.response.text,)
202
+ finally:
203
+ print(str(e))
204
+ return res
205
+
206
+ try:
207
+ response = res.json()
208
+ except requests.exceptions.JSONDecodeError:
209
+ response = res.text
210
+
211
+ return response
212
+
213
+
214
+ class NerisApiClient(_NerisApiClient):
215
+ def health(self):
216
+ return self._call("get", "/health")
217
+
218
+ def get_entity(self, neris_id: str) -> Dict[str, Any]:
219
+ return self._call("get", f"/entity/{neris_id}")
220
+
221
+ def create_entity(self, body: str | Dict[str, Any]) -> Dict[str, Any]:
222
+ return self._call("post", "/entity/", body, model=CreateDepartmentPayload)
223
+
224
+ def update_entity(self, neris_id: str, body: str | Dict[str, Any]) -> Dict[str, Any]:
225
+ return self._call("put", f"/entity/{neris_id}", body, model=DepartmentPayload)
226
+
227
+ def get_user(self, sub: str | UUID) -> Dict[str, Any]:
228
+ return self._call("get", f"/user/{sub}")
229
+
230
+ def create_user(self, body: str | Dict[str, Any]) -> Dict[str, Any]:
231
+ return self._call("post", "/user", body, model=CreateUserPayload)
232
+
233
+ def update_user(self, sub: str | UUID, body: str | Dict[str, Any]) -> Dict[str, Any]:
234
+ return self._call("put", f"/user/{sub}", body, model=UpdateUserPayload)
235
+
236
+ def delete_user(self, sub: str | UUID) -> None:
237
+ self._call("delete", f"/user/{sub}")
238
+
239
+ def create_user_role_entity_set_attachment(self, sub_user: str | UUID, nuid_role: str | UUID, nuid_entity_set: str | UUID) -> Dict[str, Any]:
240
+ return self._call(
241
+ "post",
242
+ "/auth/user_role_entity_set_attachment",
243
+ params={"sub_user": str(sub_user), "nuid_role": str(nuid_role), "nuid_entity_set": str(nuid_entity_set)},
244
+ )
245
+
246
+ def create_user_entity_membership(self, sub: str | UUID, neris_id: str) -> None:
247
+ self._call("post", f"/user/{sub}/user_entity_membership/{neris_id}")
248
+
249
+ def delete_user_entity_membership(self, sub: str | UUID, neris_id: str) -> None:
250
+ self._call("delete", f"/user/{sub}/user_entity_membership/{neris_id}")
251
+
252
+ def update_user_entity_activation(self, sub: str | UUID, neris_id: str, active: bool) -> None:
253
+ self._call(
254
+ "put",
255
+ f"/user/{sub}/user_entity_activation/{neris_id}",
256
+ data={"active": active},
257
+ )
258
+
259
+ def list_user_entity_memberships(self, sub: str | UUID) -> List[Dict[str, Any]]:
260
+ return self._call("get", f"/user/{sub}/user_entity_membership")
261
+
262
+ def create_incident(self, neris_id: str, body: str | Dict[str, Any]) -> Dict[str, Any]:
263
+ return self._call("post", f"/incident/{neris_id}", data=body, model=IncidentPayload)
264
+
265
+ def validate_incident(self, neris_id: str, body: str | Dict[str, Any]) -> Dict[str, Any]:
266
+ return self._call(
267
+ "post", f"/incident/{neris_id}/validate", data=body, model=IncidentPayload
268
+ )
269
+
270
+ def patch_entity(self, neris_id: str, body: str | Dict[str, Any]) -> Dict[str, Any]:
271
+ return self._call("patch", f"/entity/{neris_id}", data=body, model=PatchDepartmentPayload)
272
+
273
+ def patch_station(
274
+ self, neris_id_entity: str, neris_id_station: str, body: str | Dict[str, Any]
275
+ ) -> Dict[str, Any]:
276
+ return self._call(
277
+ "patch",
278
+ f"/entity/{neris_id_entity}/station/{neris_id_station}",
279
+ data=body,
280
+ model=PatchStationPayload,
281
+ )
282
+
283
+ def patch_unit(
284
+ self,
285
+ neris_id_entity: str,
286
+ neris_id_station: str,
287
+ neris_id_unit: str,
288
+ body: str | Dict[str, Any],
289
+ ) -> Dict[str, Any]:
290
+ return self._call(
291
+ "patch",
292
+ f"/entity/{neris_id}/station/{neris_id_station}/unit/{neris_id_unit}",
293
+ data=body,
294
+ model=PatchUnitPayload,
295
+ )
296
+
297
+ def patch_incident(
298
+ self, neris_id_entity: str, neris_id_incident: str, body: str | Dict[str, Any]
299
+ ) -> Dict[str, Any]:
300
+ return self._call(
301
+ "patch",
302
+ f"/incident/{neris_id_entity}/{neris_id_incident}",
303
+ data=body,
304
+ model=PatchIncidentAction,
305
+ )
306
+
307
+ def update_incident_status(
308
+ self, neris_id_entity: str, neris_id_incident: str, status: TypeIncidentStatusValue
309
+ ) -> Dict[str, Any]:
310
+ return self._call(
311
+ "put",
312
+ f"/incident/{neris_id_entity}/{neris_id_incident}/status",
313
+ data={"status": str(status)},
314
+ )
315
+
316
+ def create_api_integration(self, neris_id: str, title: str) -> Dict[str, Any]:
317
+ return self._call("post", f"/account/integration/{neris_id}", data={ "title": title })
318
+
319
+ def generate_api_secret(self, client_id: str) -> Dict[str, Any]:
320
+ return self._call("post", f"/account/credential/{client_id}")
321
+
322
+ def list_integrations(self, neris_id: str) -> Dict[str, Any]:
323
+ return self._call("get", f"/account/integration/{neris_id}/list")
324
+
325
+ def enroll_integration(self, neris_id: str, client_id: UUID | str) -> Dict[str, Any]:
326
+ return self._call("post", f"/account/enrollment/{neris_id}/{client_id}")
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ import os
5
+
6
+ class GrantType(str, Enum):
7
+ CLIENT_CREDENTIALS = "client_credentials"
8
+ PASSWORD = "password"
9
+
10
+ @dataclass
11
+ class Config:
12
+ base_url: str | None = None
13
+ debug: bool | None = None
14
+ username: str | None = None
15
+ password: str | None = None
16
+ client_id: str | None = None
17
+ client_secret: str | None = None
18
+ grant_type: GrantType | None = None
19
+
20
+ def __post_init__(self):
21
+ # env var handling
22
+ self.base_url = self.base_url or os.getenv("NERIS_BASE_URL")
23
+ self.debug = self.debug if self.debug is not None else os.getenv("NERIS_DEBUG") == "true"
24
+
25
+ match os.getenv("NERIS_GRANT_TYPE"):
26
+ case GrantType.PASSWORD:
27
+ self.grant_type = self.grant_type or GrantType.PASSWORD
28
+ self.username = self.username or os.getenv("NERIS_USERNAME")
29
+ self.password = self.password or os.getenv("NERIS_PASSWORD")
30
+ case GrantType.CLIENT_CREDENTIALS:
31
+ self.grant_type = self.grant_type or GrantType.CLIENT_CREDENTIALS
32
+ self.client_id = self.client_id or os.getenv("NERIS_CLIENT_ID")
33
+ self.client_secret = self.client_secret or os.getenv("NERIS_CLIENT_SECRET")
34
+
35
+
36
+ @dataclass
37
+ class TokenSet:
38
+ access_token: str
39
+ refresh_token: str
40
+ expires_at: datetime