neris-api-client 1.1.0__tar.gz → 1.2.0.post0__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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.1
2
+ Name: neris_api_client
3
+ Version: 1.2.0.post0
4
+ Summary: NERIS API Client
5
+ Author-email: Ben Coleman <ben.coleman@ul.org>, Tommy Messbauer <thomas.messbauer@ul.org>
6
+ Project-URL: Homepage, https://github.com/ulfsri/neris-api-client
7
+ Project-URL: Issues, https://neris.atlassian.net/servicedesk/customer/portal/3/group/3/create/10027
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests
15
+ Requires-Dist: pydantic[email]>=2
16
+ Requires-Dist: python-dotenv
17
+
18
+ # NERIS API Client
19
+
20
+ A Python class to interact with the NERIS API programmatically.
21
+
22
+ ## Installation
23
+
24
+ To install:
25
+ ```bash
26
+ pip install neris-api-client
27
+ ```
28
+ Package published on PyPi: [`NERIS API Client`](https://pypi.org/project/neris-api-client/)
29
+
30
+ ## Usage
31
+
32
+ **Auth with password `auth` flow**
33
+
34
+ All config parameters can be specified as environment variables prefixed with `NERIS_` e.g. `NERIS_GRANT_TYPE=password`.
35
+ ```python
36
+ from neris_api_client import NerisApiClient, Config
37
+
38
+ client = NerisApiClient(
39
+ Config(
40
+ base_url="https://api-test.neris.fsri.org/v1",
41
+ grant_type="password",
42
+ username="neris.user@email.co",
43
+ password="*******",
44
+ )
45
+ )
46
+
47
+ # Get an entity
48
+ entity = client.get_entity("FD24027240")
49
+
50
+ # If MFA enabled, prompt for auth challenge answer
51
+ Provide MFA code for email_otp: ******
52
+ ```
53
+
54
+ **Auth with `client_credentials` auth flow**
55
+
56
+ All config parameters can be specified as environment variables prefixed with `NERIS_` e.g. `NERIS_GRANT_TYPE=client_credentials`.
57
+ ```python
58
+ from neris_api_client import NerisApiClient, Config
59
+
60
+ client = NerisApiClient(
61
+ Config(
62
+ base_url="https://api-test.neris.fsri.org/v1",
63
+ grant_type="client_credentials",
64
+ client_id="********************",
65
+ client_secret="***************",
66
+ )
67
+ )
68
+
69
+ # Get an entity
70
+ entity = client.get_entity("FD24027240")
71
+ ```
72
+
73
+ **Using environment varables to instantiate the library**
74
+
75
+ [Configuration should be stored in the environment](https://12factor.net/config). The following uses `dotenv` to load the configuration, but you can use whatever you like. In the cloud, you might load all of these values into the environment from an encrypted parameter store.
76
+
77
+ All variables prefixed are prefixed with `NERIS_`
78
+
79
+ * See [env.example](./examples/.env.example) for the parameter options.
80
+ * Store the `/env` file in the current working directory.
81
+
82
+ ```python
83
+ from dotenv import load_dotenv
84
+ from neris_api_client import NerisApiClient, Config
85
+
86
+ # Credentials must be defined in .env
87
+ load_dotenv()
88
+ client = NerisApiClient()
89
+
90
+ # Get an entity
91
+ entity = client.get_entity("FD24027240")
92
+ ```
93
+
94
+ ## Disclaimer
95
+ The models in this package are generated using [`datamodel-code-generator`](https://github.com/koxudaxi/datamodel-code-generator) and are not guaranteed
96
+ to contain all of the information present in the full specification. The generated models are used to validate request payloads prior to
97
+ API submission.
98
+
99
+ ## Additional Information
100
+ To ask a question, make a suggestion, or otherwise get help with the NERIS API Client, please visit [the NERIS helpdesk](https://neris.atlassian.net/servicedesk/customer/portals).
101
+
102
+ **Beta sandbox users**:
103
+ - Be sure to use the base URL `https://api-test.neris.fsri.org/v1` with the NERIS API Client. You will not be able to authenticate requests in the other environments.
104
+ - For the beta testing period, your user is only authorized to perform actions on behalf of the FSRI Fire Department `FD24027214`.
@@ -0,0 +1,87 @@
1
+ # NERIS API Client
2
+
3
+ A Python class to interact with the NERIS API programmatically.
4
+
5
+ ## Installation
6
+
7
+ To install:
8
+ ```bash
9
+ pip install neris-api-client
10
+ ```
11
+ Package published on PyPi: [`NERIS API Client`](https://pypi.org/project/neris-api-client/)
12
+
13
+ ## Usage
14
+
15
+ **Auth with password `auth` flow**
16
+
17
+ All config parameters can be specified as environment variables prefixed with `NERIS_` e.g. `NERIS_GRANT_TYPE=password`.
18
+ ```python
19
+ from neris_api_client import NerisApiClient, Config
20
+
21
+ client = NerisApiClient(
22
+ Config(
23
+ base_url="https://api-test.neris.fsri.org/v1",
24
+ grant_type="password",
25
+ username="neris.user@email.co",
26
+ password="*******",
27
+ )
28
+ )
29
+
30
+ # Get an entity
31
+ entity = client.get_entity("FD24027240")
32
+
33
+ # If MFA enabled, prompt for auth challenge answer
34
+ Provide MFA code for email_otp: ******
35
+ ```
36
+
37
+ **Auth with `client_credentials` auth flow**
38
+
39
+ All config parameters can be specified as environment variables prefixed with `NERIS_` e.g. `NERIS_GRANT_TYPE=client_credentials`.
40
+ ```python
41
+ from neris_api_client import NerisApiClient, Config
42
+
43
+ client = NerisApiClient(
44
+ Config(
45
+ base_url="https://api-test.neris.fsri.org/v1",
46
+ grant_type="client_credentials",
47
+ client_id="********************",
48
+ client_secret="***************",
49
+ )
50
+ )
51
+
52
+ # Get an entity
53
+ entity = client.get_entity("FD24027240")
54
+ ```
55
+
56
+ **Using environment varables to instantiate the library**
57
+
58
+ [Configuration should be stored in the environment](https://12factor.net/config). The following uses `dotenv` to load the configuration, but you can use whatever you like. In the cloud, you might load all of these values into the environment from an encrypted parameter store.
59
+
60
+ All variables prefixed are prefixed with `NERIS_`
61
+
62
+ * See [env.example](./examples/.env.example) for the parameter options.
63
+ * Store the `/env` file in the current working directory.
64
+
65
+ ```python
66
+ from dotenv import load_dotenv
67
+ from neris_api_client import NerisApiClient, Config
68
+
69
+ # Credentials must be defined in .env
70
+ load_dotenv()
71
+ client = NerisApiClient()
72
+
73
+ # Get an entity
74
+ entity = client.get_entity("FD24027240")
75
+ ```
76
+
77
+ ## Disclaimer
78
+ The models in this package are generated using [`datamodel-code-generator`](https://github.com/koxudaxi/datamodel-code-generator) and are not guaranteed
79
+ to contain all of the information present in the full specification. The generated models are used to validate request payloads prior to
80
+ API submission.
81
+
82
+ ## Additional Information
83
+ To ask a question, make a suggestion, or otherwise get help with the NERIS API Client, please visit [the NERIS helpdesk](https://neris.atlassian.net/servicedesk/customer/portals).
84
+
85
+ **Beta sandbox users**:
86
+ - Be sure to use the base URL `https://api-test.neris.fsri.org/v1` with the NERIS API Client. You will not be able to authenticate requests in the other environments.
87
+ - For the beta testing period, your user is only authorized to perform actions on behalf of the FSRI Fire Department `FD24027214`.
@@ -4,9 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "neris_api_client"
7
- version = "1.1.0"
7
+ version = "1.2.0.post0"
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