hotglue-singer-sdk 1.0.2__py3-none-any.whl
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.
- hotglue_singer_sdk/__init__.py +34 -0
- hotglue_singer_sdk/authenticators.py +554 -0
- hotglue_singer_sdk/cli/__init__.py +1 -0
- hotglue_singer_sdk/cli/common_options.py +37 -0
- hotglue_singer_sdk/configuration/__init__.py +1 -0
- hotglue_singer_sdk/configuration/_dict_config.py +101 -0
- hotglue_singer_sdk/exceptions.py +52 -0
- hotglue_singer_sdk/helpers/__init__.py +1 -0
- hotglue_singer_sdk/helpers/_catalog.py +122 -0
- hotglue_singer_sdk/helpers/_classproperty.py +18 -0
- hotglue_singer_sdk/helpers/_compat.py +15 -0
- hotglue_singer_sdk/helpers/_flattening.py +374 -0
- hotglue_singer_sdk/helpers/_schema.py +100 -0
- hotglue_singer_sdk/helpers/_secrets.py +41 -0
- hotglue_singer_sdk/helpers/_simpleeval.py +678 -0
- hotglue_singer_sdk/helpers/_singer.py +280 -0
- hotglue_singer_sdk/helpers/_state.py +282 -0
- hotglue_singer_sdk/helpers/_typing.py +231 -0
- hotglue_singer_sdk/helpers/_util.py +27 -0
- hotglue_singer_sdk/helpers/capabilities.py +240 -0
- hotglue_singer_sdk/helpers/jsonpath.py +39 -0
- hotglue_singer_sdk/io_base.py +134 -0
- hotglue_singer_sdk/mapper.py +691 -0
- hotglue_singer_sdk/mapper_base.py +156 -0
- hotglue_singer_sdk/plugin_base.py +415 -0
- hotglue_singer_sdk/py.typed +0 -0
- hotglue_singer_sdk/sinks/__init__.py +14 -0
- hotglue_singer_sdk/sinks/batch.py +90 -0
- hotglue_singer_sdk/sinks/core.py +412 -0
- hotglue_singer_sdk/sinks/record.py +66 -0
- hotglue_singer_sdk/sinks/sql.py +299 -0
- hotglue_singer_sdk/streams/__init__.py +14 -0
- hotglue_singer_sdk/streams/core.py +1294 -0
- hotglue_singer_sdk/streams/graphql.py +74 -0
- hotglue_singer_sdk/streams/rest.py +611 -0
- hotglue_singer_sdk/streams/sql.py +1023 -0
- hotglue_singer_sdk/tap_base.py +580 -0
- hotglue_singer_sdk/target_base.py +554 -0
- hotglue_singer_sdk/target_sdk/__init__.py +0 -0
- hotglue_singer_sdk/target_sdk/auth.py +124 -0
- hotglue_singer_sdk/target_sdk/client.py +286 -0
- hotglue_singer_sdk/target_sdk/common.py +13 -0
- hotglue_singer_sdk/target_sdk/lambda.py +121 -0
- hotglue_singer_sdk/target_sdk/rest.py +108 -0
- hotglue_singer_sdk/target_sdk/sinks.py +16 -0
- hotglue_singer_sdk/target_sdk/target.py +570 -0
- hotglue_singer_sdk/target_sdk/target_base.py +627 -0
- hotglue_singer_sdk/testing.py +198 -0
- hotglue_singer_sdk/typing.py +603 -0
- hotglue_singer_sdk-1.0.2.dist-info/METADATA +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/RECORD +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/WHEEL +4 -0
- hotglue_singer_sdk-1.0.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""SDK for building singer-compliant Singer taps."""
|
|
2
|
+
|
|
3
|
+
from hotglue_singer_sdk import streams
|
|
4
|
+
from hotglue_singer_sdk.mapper_base import InlineMapper
|
|
5
|
+
from hotglue_singer_sdk.plugin_base import PluginBase
|
|
6
|
+
from hotglue_singer_sdk.sinks import BatchSink, RecordSink, Sink, SQLSink
|
|
7
|
+
from hotglue_singer_sdk.streams import (
|
|
8
|
+
GraphQLStream,
|
|
9
|
+
RESTStream,
|
|
10
|
+
SQLConnector,
|
|
11
|
+
SQLStream,
|
|
12
|
+
Stream,
|
|
13
|
+
)
|
|
14
|
+
from hotglue_singer_sdk.tap_base import SQLTap, Tap
|
|
15
|
+
from hotglue_singer_sdk.target_base import SQLTarget, Target
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BatchSink",
|
|
19
|
+
"GraphQLStream",
|
|
20
|
+
"InlineMapper",
|
|
21
|
+
"PluginBase",
|
|
22
|
+
"RecordSink",
|
|
23
|
+
"RESTStream",
|
|
24
|
+
"Sink",
|
|
25
|
+
"SQLConnector",
|
|
26
|
+
"SQLSink",
|
|
27
|
+
"SQLStream",
|
|
28
|
+
"SQLTap",
|
|
29
|
+
"SQLTarget",
|
|
30
|
+
"Stream",
|
|
31
|
+
"streams",
|
|
32
|
+
"Tap",
|
|
33
|
+
"Target",
|
|
34
|
+
]
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Classes to assist in authenticating to APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
import requests
|
|
15
|
+
from cryptography.hazmat.backends import default_backend
|
|
16
|
+
from cryptography.hazmat.primitives import serialization
|
|
17
|
+
from singer import utils
|
|
18
|
+
|
|
19
|
+
from hotglue_singer_sdk.helpers._util import utc_now
|
|
20
|
+
from hotglue_singer_sdk.streams import Stream as RESTStreamBase
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
|
|
24
|
+
_token_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SingletonMeta(type):
|
|
28
|
+
"""A general purpose singleton metaclass."""
|
|
29
|
+
|
|
30
|
+
def __init__(cls, name: str, bases: tuple[type], dic: dict) -> None:
|
|
31
|
+
"""Init metaclass.
|
|
32
|
+
|
|
33
|
+
The single instance is saved as an attribute of the the metaclass.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: Name of the derived class.
|
|
37
|
+
bases: Base types of the derived class.
|
|
38
|
+
dic: Class dictionary of the derived class.
|
|
39
|
+
"""
|
|
40
|
+
cls.__single_instance = None
|
|
41
|
+
super().__init__(name, bases, dic)
|
|
42
|
+
|
|
43
|
+
def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
44
|
+
"""Create or reuse the singleton.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
args: Class constructor positional arguments.
|
|
48
|
+
kwargs: Class constructor keyword arguments.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A singleton instance of the derived class.
|
|
52
|
+
"""
|
|
53
|
+
if cls.__single_instance:
|
|
54
|
+
return cls.__single_instance
|
|
55
|
+
single_obj = cls.__new__(cls, None) # type: ignore
|
|
56
|
+
single_obj.__init__(*args, **kwargs)
|
|
57
|
+
cls.__single_instance = single_obj
|
|
58
|
+
return single_obj
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class APIAuthenticatorBase:
|
|
62
|
+
"""Base class for offloading API auth."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, stream: RESTStreamBase) -> None:
|
|
65
|
+
"""Init authenticator.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
stream: A stream for a RESTful endpoint.
|
|
69
|
+
"""
|
|
70
|
+
self.tap_name: str = stream.tap_name
|
|
71
|
+
self._config: dict[str, Any] = dict(stream.config)
|
|
72
|
+
self._auth_headers: dict[str, Any] = {}
|
|
73
|
+
self._auth_params: dict[str, Any] = {}
|
|
74
|
+
self.logger: logging.Logger = stream.logger
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def config(self) -> Mapping[str, Any]:
|
|
78
|
+
"""Get stream or tap config.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A frozen (read-only) config dictionary map.
|
|
82
|
+
"""
|
|
83
|
+
return MappingProxyType(self._config)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def auth_headers(self) -> dict:
|
|
87
|
+
"""Get headers.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
HTTP headers for authentication.
|
|
91
|
+
"""
|
|
92
|
+
return self._auth_headers or {}
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def auth_params(self) -> dict:
|
|
96
|
+
"""Get query parameters.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
URL query parameters for authentication.
|
|
100
|
+
"""
|
|
101
|
+
return self._auth_params or {}
|
|
102
|
+
|
|
103
|
+
def authenticate_request(self, request: requests.Request) -> None:
|
|
104
|
+
"""Authenticate a request.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
request: A `request object`_.
|
|
108
|
+
|
|
109
|
+
.. _request object:
|
|
110
|
+
https://requests.readthedocs.io/en/latest/api/#requests.Request
|
|
111
|
+
"""
|
|
112
|
+
request.headers.update(self.auth_headers)
|
|
113
|
+
request.params.update(self.auth_params)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SimpleAuthenticator(APIAuthenticatorBase):
|
|
117
|
+
"""DEPRECATED: Please use a more specific authenticator.
|
|
118
|
+
|
|
119
|
+
This authenticator will merge a key-value pair to the stream
|
|
120
|
+
in either the request headers or query parameters.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
stream: RESTStreamBase,
|
|
126
|
+
auth_headers: dict | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Create a new authenticator.
|
|
129
|
+
|
|
130
|
+
If auth_headers is provided, it will be merged with http_headers specified on
|
|
131
|
+
the stream.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
stream: The stream instance to use with this authenticator.
|
|
135
|
+
auth_headers: Authentication headers.
|
|
136
|
+
"""
|
|
137
|
+
super().__init__(stream=stream)
|
|
138
|
+
if self._auth_headers is None:
|
|
139
|
+
self._auth_headers = {}
|
|
140
|
+
if auth_headers:
|
|
141
|
+
self._auth_headers.update(auth_headers)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class APIKeyAuthenticator(APIAuthenticatorBase):
|
|
145
|
+
"""Implements API key authentication for REST Streams.
|
|
146
|
+
|
|
147
|
+
This authenticator will merge a key-value pair with either the
|
|
148
|
+
HTTP headers or query parameters specified on the stream. Common
|
|
149
|
+
examples of key names are "x-api-key" and "Authorization" but
|
|
150
|
+
any key-value pair may be used for this authenticator.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
stream: RESTStreamBase,
|
|
156
|
+
key: str,
|
|
157
|
+
value: str,
|
|
158
|
+
location: str = "header",
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Create a new authenticator.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
stream: The stream instance to use with this authenticator.
|
|
164
|
+
key: API key parameter name.
|
|
165
|
+
value: API key value.
|
|
166
|
+
location: Where the API key is to be added. Either 'header' or 'params'.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If the location value is not 'header' or 'params'.
|
|
170
|
+
"""
|
|
171
|
+
super().__init__(stream=stream)
|
|
172
|
+
auth_credentials = {key: value}
|
|
173
|
+
|
|
174
|
+
if location not in ["header", "params"]:
|
|
175
|
+
raise ValueError("`type` must be one of 'header' or 'params'.")
|
|
176
|
+
|
|
177
|
+
if location == "header":
|
|
178
|
+
if self._auth_headers is None:
|
|
179
|
+
self._auth_headers = {}
|
|
180
|
+
self._auth_headers.update(auth_credentials)
|
|
181
|
+
elif location == "params":
|
|
182
|
+
if self._auth_params is None:
|
|
183
|
+
self._auth_params = {}
|
|
184
|
+
self._auth_params.update(auth_credentials)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def create_for_stream(
|
|
188
|
+
cls: type[APIKeyAuthenticator],
|
|
189
|
+
stream: RESTStreamBase,
|
|
190
|
+
key: str,
|
|
191
|
+
value: str,
|
|
192
|
+
location: str,
|
|
193
|
+
) -> APIKeyAuthenticator:
|
|
194
|
+
"""Create an Authenticator object specific to the Stream class.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
stream: The stream instance to use with this authenticator.
|
|
198
|
+
key: API key parameter name.
|
|
199
|
+
value: API key value.
|
|
200
|
+
location: Where the API key is to be added. Either 'header' or 'params'.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
APIKeyAuthenticator: A new
|
|
204
|
+
:class:`hotglue_singer_sdk.authenticators.APIKeyAuthenticator` instance.
|
|
205
|
+
"""
|
|
206
|
+
return cls(stream=stream, key=key, value=value, location=location)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class BearerTokenAuthenticator(APIAuthenticatorBase):
|
|
210
|
+
"""Implements bearer token authentication for REST Streams.
|
|
211
|
+
|
|
212
|
+
This Authenticator implements Bearer Token authentication. The token
|
|
213
|
+
is a text string, included in the request header and prefixed with
|
|
214
|
+
'Bearer '. The token will be merged with HTTP headers on the stream.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, stream: RESTStreamBase, token: str) -> None:
|
|
218
|
+
"""Create a new authenticator.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
stream: The stream instance to use with this authenticator.
|
|
222
|
+
token: Authentication token.
|
|
223
|
+
"""
|
|
224
|
+
super().__init__(stream=stream)
|
|
225
|
+
auth_credentials = {"Authorization": f"Bearer {token}"}
|
|
226
|
+
|
|
227
|
+
if self._auth_headers is None:
|
|
228
|
+
self._auth_headers = {}
|
|
229
|
+
self._auth_headers.update(auth_credentials)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def create_for_stream(
|
|
233
|
+
cls: type[BearerTokenAuthenticator], stream: RESTStreamBase, token: str
|
|
234
|
+
) -> BearerTokenAuthenticator:
|
|
235
|
+
"""Create an Authenticator object specific to the Stream class.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
stream: The stream instance to use with this authenticator.
|
|
239
|
+
token: Authentication token.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
BearerTokenAuthenticator: A new
|
|
243
|
+
:class:`hotglue_singer_sdk.authenticators.BearerTokenAuthenticator` instance.
|
|
244
|
+
"""
|
|
245
|
+
return cls(stream=stream, token=token)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class BasicAuthenticator(APIAuthenticatorBase):
|
|
249
|
+
"""Implements basic authentication for REST Streams.
|
|
250
|
+
|
|
251
|
+
This Authenticator implements basic authentication by concatinating a
|
|
252
|
+
username and password then base64 encoding the string. The resulting
|
|
253
|
+
token will be merged with any HTTP headers specified on the stream.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
stream: RESTStreamBase,
|
|
259
|
+
username: str,
|
|
260
|
+
password: str,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Create a new authenticator.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
stream: The stream instance to use with this authenticator.
|
|
266
|
+
username: API username.
|
|
267
|
+
password: API password.
|
|
268
|
+
"""
|
|
269
|
+
super().__init__(stream=stream)
|
|
270
|
+
credentials = f"{username}:{password}".encode()
|
|
271
|
+
auth_token = base64.b64encode(credentials).decode("ascii")
|
|
272
|
+
auth_credentials = {"Authorization": f"Basic {auth_token}"}
|
|
273
|
+
|
|
274
|
+
if self._auth_headers is None:
|
|
275
|
+
self._auth_headers = {}
|
|
276
|
+
self._auth_headers.update(auth_credentials)
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def create_for_stream(
|
|
280
|
+
cls: type[BasicAuthenticator],
|
|
281
|
+
stream: RESTStreamBase,
|
|
282
|
+
username: str,
|
|
283
|
+
password: str,
|
|
284
|
+
) -> BasicAuthenticator:
|
|
285
|
+
"""Create an Authenticator object specific to the Stream class.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
stream: The stream instance to use with this authenticator.
|
|
289
|
+
username: API username.
|
|
290
|
+
password: API password.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
BasicAuthenticator: A new
|
|
294
|
+
:class:`hotglue_singer_sdk.authenticators.BasicAuthenticator` instance.
|
|
295
|
+
"""
|
|
296
|
+
return cls(stream=stream, username=username, password=password)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class OAuthAuthenticator(APIAuthenticatorBase):
|
|
300
|
+
"""API Authenticator for OAuth 2.0 flows."""
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
stream: RESTStreamBase,
|
|
305
|
+
auth_endpoint: str | None = None,
|
|
306
|
+
oauth_scopes: str | None = None,
|
|
307
|
+
default_expiration: int | None = None,
|
|
308
|
+
config_file: str | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Create a new authenticator.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
stream: The stream instance to use with this authenticator.
|
|
314
|
+
auth_endpoint: API username.
|
|
315
|
+
oauth_scopes: API password.
|
|
316
|
+
default_expiration: Default token expiry in seconds.
|
|
317
|
+
"""
|
|
318
|
+
super().__init__(stream=stream)
|
|
319
|
+
self._auth_endpoint = auth_endpoint
|
|
320
|
+
self._default_expiration = default_expiration
|
|
321
|
+
self._oauth_scopes = oauth_scopes
|
|
322
|
+
|
|
323
|
+
# Initialize internal tracking attributes
|
|
324
|
+
self.access_token: str | None = None
|
|
325
|
+
self.refresh_token: str | None = None
|
|
326
|
+
self.last_refreshed: datetime | None = None
|
|
327
|
+
self.expires_in: int | None = None
|
|
328
|
+
self._config_file = config_file
|
|
329
|
+
self._tap = stream._tap
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def auth_headers(self) -> dict:
|
|
333
|
+
"""Return a dictionary of auth headers to be applied.
|
|
334
|
+
|
|
335
|
+
These will be merged with any `http_headers` specified in the stream.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
HTTP headers for authentication.
|
|
339
|
+
"""
|
|
340
|
+
if not self.is_token_valid():
|
|
341
|
+
with _token_lock:
|
|
342
|
+
self.logger.info(f"[{threading.current_thread.__name__}] Token expired, locking and attempting to refresh token.")
|
|
343
|
+
if not self.is_token_valid():
|
|
344
|
+
self.update_access_token()
|
|
345
|
+
result = super().auth_headers
|
|
346
|
+
result["Authorization"] = f"Bearer {self.access_token}"
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def auth_endpoint(self) -> str:
|
|
351
|
+
"""Get the authorization endpoint.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The API authorization endpoint if it is set.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ValueError: If the endpoint is not set.
|
|
358
|
+
"""
|
|
359
|
+
if not self._auth_endpoint:
|
|
360
|
+
raise ValueError("Authorization endpoint not set.")
|
|
361
|
+
return self._auth_endpoint
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def oauth_scopes(self) -> str | None:
|
|
365
|
+
"""Get OAuth scopes.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
String of OAuth scopes, or None if not set.
|
|
369
|
+
"""
|
|
370
|
+
return self._oauth_scopes
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def oauth_request_payload(self) -> dict:
|
|
374
|
+
"""Get request body.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
A plain (OAuth) or encrypted (JWT) request body.
|
|
378
|
+
"""
|
|
379
|
+
return self.oauth_request_body
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def oauth_request_body(self) -> dict:
|
|
383
|
+
"""Get formatted body of the OAuth authorization request.
|
|
384
|
+
|
|
385
|
+
Sample implementation:
|
|
386
|
+
|
|
387
|
+
.. highlight:: python
|
|
388
|
+
.. code-block:: python
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def oauth_request_body(self) -> dict:
|
|
392
|
+
return {
|
|
393
|
+
'grant_type': 'password',
|
|
394
|
+
'scope': 'https://api.powerbi.com',
|
|
395
|
+
'resource': 'https://analysis.windows.net/powerbi/api',
|
|
396
|
+
'client_id': self.config["client_id"],
|
|
397
|
+
'username': self.config.get("username", self.config["client_id"]),
|
|
398
|
+
'password': self.config["password"],
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
NotImplementedError: If derived class does not override this method.
|
|
403
|
+
"""
|
|
404
|
+
raise NotImplementedError(
|
|
405
|
+
"The `oauth_request_body` property was not defined in the subclass."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def client_id(self) -> str | None:
|
|
410
|
+
"""Get client ID string to be used in authentication.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Optional client secret from stream config if it has been set.
|
|
414
|
+
"""
|
|
415
|
+
if self.config:
|
|
416
|
+
return self.config.get("client_id")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def client_secret(self) -> str | None:
|
|
421
|
+
"""Get client secret to be used in authentication.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Optional client secret from stream config if it has been set.
|
|
425
|
+
"""
|
|
426
|
+
if self.config:
|
|
427
|
+
return self.config.get("client_secret")
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
def is_token_valid(self) -> bool:
|
|
431
|
+
"""Check if token is valid.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
True if the token is valid (fresh).
|
|
435
|
+
"""
|
|
436
|
+
# if expires_in is not set, try to get it from the tap config
|
|
437
|
+
if self.expires_in is None and self._tap.config.get("expires_in"):
|
|
438
|
+
self.expires_in = self._tap.config.get("expires_in")
|
|
439
|
+
|
|
440
|
+
if self.last_refreshed is None:
|
|
441
|
+
return False
|
|
442
|
+
if not self.expires_in:
|
|
443
|
+
return True
|
|
444
|
+
if self.expires_in - int(utils.now().timestamp()) > 120:
|
|
445
|
+
return True
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
def request_auth(self) -> tuple[str, str]:
|
|
449
|
+
"""Return the authentication credentials for the request."""
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
def update_access_token(self) -> None:
|
|
453
|
+
"""Update `access_token` along with: `last_refreshed` and `expires_in`.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
RuntimeError: When OAuth login fails.
|
|
457
|
+
"""
|
|
458
|
+
request_time = utc_now()
|
|
459
|
+
auth_request_payload = self.oauth_request_payload
|
|
460
|
+
token_response = requests.post(self.auth_endpoint, data=auth_request_payload, auth=self.request_auth())
|
|
461
|
+
try:
|
|
462
|
+
token_response.raise_for_status()
|
|
463
|
+
self.logger.info("OAuth authorization attempt was successful.")
|
|
464
|
+
except Exception as ex:
|
|
465
|
+
raise RuntimeError(
|
|
466
|
+
f"Failed OAuth login, response was '{token_response.json()}'. {ex}"
|
|
467
|
+
)
|
|
468
|
+
token_json = token_response.json()
|
|
469
|
+
self.access_token = token_json["access_token"]
|
|
470
|
+
self.expires_in = token_json.get("expires_in", self._default_expiration) + int(request_time.timestamp())
|
|
471
|
+
if self.expires_in is None:
|
|
472
|
+
self.logger.debug(
|
|
473
|
+
"No expires_in receied in OAuth response and no "
|
|
474
|
+
"default_expiration set. Token will be treated as if it never "
|
|
475
|
+
"expires."
|
|
476
|
+
)
|
|
477
|
+
self.last_refreshed = request_time
|
|
478
|
+
# Update the tap config with the new access_token and refresh_token
|
|
479
|
+
self._tap._config["access_token"] = token_json["access_token"]
|
|
480
|
+
self._tap._config["expires_in"] = self.expires_in
|
|
481
|
+
if token_json.get("refresh_token"):
|
|
482
|
+
#Log the refresh_token
|
|
483
|
+
self._tap.logger.info(f"Latest refresh token: {token_json.get('refresh_token')}")
|
|
484
|
+
self._tap._config["refresh_token"] = token_json["refresh_token"]
|
|
485
|
+
|
|
486
|
+
# Write the updated config back to the file
|
|
487
|
+
with open(self._tap.config_file, "w") as outfile:
|
|
488
|
+
json.dump(self._tap._config, outfile, indent=4)
|
|
489
|
+
|
|
490
|
+
class OAuthJWTAuthenticator(OAuthAuthenticator):
|
|
491
|
+
"""API Authenticator for OAuth 2.0 flows which utilize a JWT refresh token."""
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def private_key(self) -> str | None:
|
|
495
|
+
"""Return the private key to use in encryption.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Private key from stream config.
|
|
499
|
+
"""
|
|
500
|
+
return self.config.get("private_key", None)
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def private_key_passphrase(self) -> str | None:
|
|
504
|
+
"""Return the private key passphrase to use in encryption.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Passphrase for private key from stream config.
|
|
508
|
+
"""
|
|
509
|
+
return self.config.get("private_key_passphrase", None)
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def oauth_request_body(self) -> dict:
|
|
513
|
+
"""Return request body for OAuth request.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Request body mapping for OAuth.
|
|
517
|
+
"""
|
|
518
|
+
request_time = utc_now()
|
|
519
|
+
return {
|
|
520
|
+
"iss": self.client_id,
|
|
521
|
+
"scope": self.oauth_scopes,
|
|
522
|
+
"aud": self.auth_endpoint,
|
|
523
|
+
"exp": math.floor((request_time + timedelta(hours=1)).timestamp()),
|
|
524
|
+
"iat": math.floor(request_time.timestamp()),
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def oauth_request_payload(self) -> dict:
|
|
529
|
+
"""Return request paytload for OAuth request.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Payload object for OAuth.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
ValueError: If the private key is not set.
|
|
536
|
+
"""
|
|
537
|
+
if not self.private_key:
|
|
538
|
+
raise ValueError("Missing 'private_key' property for OAuth payload.")
|
|
539
|
+
|
|
540
|
+
private_key: bytes | Any = bytes(self.private_key, "UTF-8")
|
|
541
|
+
if self.private_key_passphrase:
|
|
542
|
+
passphrase = bytes(self.private_key_passphrase, "UTF-8")
|
|
543
|
+
private_key = serialization.load_pem_private_key(
|
|
544
|
+
private_key,
|
|
545
|
+
password=passphrase,
|
|
546
|
+
backend=default_backend(),
|
|
547
|
+
)
|
|
548
|
+
private_key_string: str | Any = private_key.decode("UTF-8")
|
|
549
|
+
return {
|
|
550
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
551
|
+
"assertion": jwt.encode(
|
|
552
|
+
self.oauth_request_body, private_key_string, "RS256"
|
|
553
|
+
),
|
|
554
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helpers for the tap, target and mapper CLIs."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Common CLI options for plugins."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
PLUGIN_VERSION = click.option(
|
|
6
|
+
"--version",
|
|
7
|
+
is_flag=True,
|
|
8
|
+
help="Display the package version.",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
PLUGIN_ABOUT = click.option(
|
|
12
|
+
"--about",
|
|
13
|
+
is_flag=True,
|
|
14
|
+
help="Display package metadata and settings.",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PLUGIN_ABOUT_FORMAT = click.option(
|
|
18
|
+
"--format",
|
|
19
|
+
help="Specify output style for --about",
|
|
20
|
+
type=click.Choice(["json", "markdown"], case_sensitive=False),
|
|
21
|
+
default=None,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PLUGIN_CONFIG = click.option(
|
|
25
|
+
"--config",
|
|
26
|
+
multiple=True,
|
|
27
|
+
help="Configuration file location or 'ENV' to use environment variables.",
|
|
28
|
+
type=click.STRING,
|
|
29
|
+
default=(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
PLUGIN_FILE_INPUT = click.option(
|
|
33
|
+
"--input",
|
|
34
|
+
"file_input",
|
|
35
|
+
help="A path to read messages from instead of from standard in.",
|
|
36
|
+
type=click.File("r"),
|
|
37
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration parsing and handling."""
|