connector-py 4.179.0__py3-none-any.whl → 4.181.0__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.
- connector/__about__.py +1 -1
- connector/client.py +6 -1
- connector/generated/__init__.py +0 -4
- connector/generated/models/__init__.py +0 -4
- connector/oai/base_clients.py +101 -6
- connector/oai/capabilities/executor.py +2 -2
- connector/oai/capabilities/factory.py +5 -2
- connector/oai/capability.py +13 -0
- connector/oai/integration.py +21 -5
- connector/oai/modules/credentials_module.py +282 -0
- connector/oai/modules/info_module.py +126 -82
- connector/oai/modules/oauth_module.py +1 -1
- connector/oai/modules/oauth_module_types.py +0 -2
- connector/spec/openapi.yaml +233 -63
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/METADATA +2 -2
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/RECORD +30 -28
- tests/oai/capabilities/test_executor_cases.py +3 -3
- tests/oai/capabilities/test_factory.py +1 -1
- tests/oai/schema_linter_checks.py +191 -0
- tests/oai/test_appinfo_all_connectors.py +155 -0
- tests/oai/test_base_clients.py +366 -1
- tests/oai/test_credentials_module.py +740 -0
- tests/oai/test_dispatch_cases.py +1 -2
- tests/oai/test_info_cases.py +484 -2
- tests/oai/test_info_module.py +2667 -19
- tests/oai/test_oauth_module.py +1 -2
- connector/generated/models/auth_model.py +0 -9
- connector/generated/models/credential_config.py +0 -9
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/WHEEL +0 -0
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/entry_points.txt +0 -0
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/licenses/LICENSE.txt +0 -0
- {connector_py-4.179.0.dist-info → connector_py-4.181.0.dist-info}/top_level.txt +0 -0
connector/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "4.
|
|
1
|
+
__version__ = "4.181.0"
|
connector/client.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
from .httpx_rewrite import AsyncClient, GqlHTTPXAsyncTransport, HTTPXAsyncTransport
|
|
2
|
-
from .oai.base_clients import
|
|
2
|
+
from .oai.base_clients import (
|
|
3
|
+
BaseGraphQLSession,
|
|
4
|
+
BaseIntegrationClient,
|
|
5
|
+
RateLimitedHTTPXAsyncTransport,
|
|
6
|
+
)
|
|
3
7
|
from .oai.capability import (
|
|
4
8
|
get_basic_auth,
|
|
5
9
|
get_jwt_auth,
|
|
@@ -20,6 +24,7 @@ from .utils.sync_to_async import sync_to_async
|
|
|
20
24
|
__all__ = [
|
|
21
25
|
"GqlHTTPXAsyncTransport",
|
|
22
26
|
"HTTPXAsyncTransport",
|
|
27
|
+
"RateLimitedHTTPXAsyncTransport",
|
|
23
28
|
"BaseGraphQLSession",
|
|
24
29
|
"BaseIntegrationClient",
|
|
25
30
|
"get_basic_auth",
|
connector/generated/__init__.py
CHANGED
|
@@ -40,7 +40,6 @@ from connector_sdk_types.generated.models.assign_entitlement_response import (
|
|
|
40
40
|
)
|
|
41
41
|
from connector_sdk_types.generated.models.assigned_entitlement import AssignedEntitlement
|
|
42
42
|
from connector_sdk_types.generated.models.auth_credential import AuthCredential
|
|
43
|
-
from connector_sdk_types.generated.models.auth_model import AuthModel
|
|
44
43
|
from connector_sdk_types.generated.models.authorization_url import AuthorizationUrl
|
|
45
44
|
from connector_sdk_types.generated.models.basic_authentication import BasicAuthentication
|
|
46
45
|
from connector_sdk_types.generated.models.basic_credential import BasicCredential
|
|
@@ -53,7 +52,6 @@ from connector_sdk_types.generated.models.create_account_request import CreateAc
|
|
|
53
52
|
from connector_sdk_types.generated.models.create_account_response import CreateAccountResponse
|
|
54
53
|
from connector_sdk_types.generated.models.created_account import CreatedAccount
|
|
55
54
|
from connector_sdk_types.generated.models.created_effect import CreatedEffect
|
|
56
|
-
from connector_sdk_types.generated.models.credential_config import CredentialConfig
|
|
57
55
|
from connector_sdk_types.generated.models.custom_attribute_customized_type import (
|
|
58
56
|
CustomAttributeCustomizedType,
|
|
59
57
|
)
|
|
@@ -320,7 +318,6 @@ __all__ = [
|
|
|
320
318
|
"AssignEntitlementResponse",
|
|
321
319
|
"AssignedEntitlement",
|
|
322
320
|
"AuthCredential",
|
|
323
|
-
"AuthModel",
|
|
324
321
|
"AuthorizationUrl",
|
|
325
322
|
"BasicAuthentication",
|
|
326
323
|
"BasicCredential",
|
|
@@ -332,7 +329,6 @@ __all__ = [
|
|
|
332
329
|
"CreateAccountRequest",
|
|
333
330
|
"CreateAccountResponse",
|
|
334
331
|
"CreatedAccount",
|
|
335
|
-
"CredentialConfig",
|
|
336
332
|
"CustomAttributeCustomizedType",
|
|
337
333
|
"CustomAttributeSchema",
|
|
338
334
|
"CustomAttributeType",
|
|
@@ -36,7 +36,6 @@ from connector_sdk_types.generated.models.assign_entitlement_response import (
|
|
|
36
36
|
)
|
|
37
37
|
from connector_sdk_types.generated.models.assigned_entitlement import AssignedEntitlement
|
|
38
38
|
from connector_sdk_types.generated.models.auth_credential import AuthCredential
|
|
39
|
-
from connector_sdk_types.generated.models.auth_model import AuthModel
|
|
40
39
|
from connector_sdk_types.generated.models.authorization_url import AuthorizationUrl
|
|
41
40
|
from connector_sdk_types.generated.models.basic_authentication import BasicAuthentication
|
|
42
41
|
from connector_sdk_types.generated.models.basic_credential import BasicCredential
|
|
@@ -48,7 +47,6 @@ from connector_sdk_types.generated.models.create_account_entitlement import Crea
|
|
|
48
47
|
from connector_sdk_types.generated.models.create_account_request import CreateAccountRequest
|
|
49
48
|
from connector_sdk_types.generated.models.create_account_response import CreateAccountResponse
|
|
50
49
|
from connector_sdk_types.generated.models.created_account import CreatedAccount
|
|
51
|
-
from connector_sdk_types.generated.models.credential_config import CredentialConfig
|
|
52
50
|
from connector_sdk_types.generated.models.custom_attribute_customized_type import (
|
|
53
51
|
CustomAttributeCustomizedType,
|
|
54
52
|
)
|
|
@@ -304,7 +302,6 @@ __all__ = [
|
|
|
304
302
|
"AssignEntitlementResponse",
|
|
305
303
|
"AssignedEntitlement",
|
|
306
304
|
"AuthCredential",
|
|
307
|
-
"AuthModel",
|
|
308
305
|
"AuthorizationUrl",
|
|
309
306
|
"BasicAuthentication",
|
|
310
307
|
"BasicCredential",
|
|
@@ -316,7 +313,6 @@ __all__ = [
|
|
|
316
313
|
"CreateAccountRequest",
|
|
317
314
|
"CreateAccountResponse",
|
|
318
315
|
"CreatedAccount",
|
|
319
|
-
"CredentialConfig",
|
|
320
316
|
"CustomAttributeCustomizedType",
|
|
321
317
|
"CustomAttributeSchema",
|
|
322
318
|
"CustomAttributeType",
|
connector/oai/base_clients.py
CHANGED
|
@@ -10,11 +10,11 @@ from connector_sdk_types.generated import ErrorCode
|
|
|
10
10
|
from gql import Client
|
|
11
11
|
from gql.client import AsyncClientSession
|
|
12
12
|
from gql.dsl import DSLSchema
|
|
13
|
-
from graphql import GraphQLSchema, build_client_schema, build_schema
|
|
13
|
+
from graphql import DocumentNode, GraphQLSchema, build_client_schema, build_schema
|
|
14
14
|
from httpx import Response
|
|
15
15
|
from typing_extensions import Self
|
|
16
16
|
|
|
17
|
-
from connector.httpx_rewrite import AsyncClient
|
|
17
|
+
from connector.httpx_rewrite import AsyncClient, HTTPXAsyncTransport
|
|
18
18
|
from connector.oai.capability import Request
|
|
19
19
|
from connector.oai.errors import ConnectorError
|
|
20
20
|
from connector.utils.rate_limiting import RateLimitConfig, RateLimiter
|
|
@@ -178,6 +178,75 @@ class RateLimitedClient(AsyncClient):
|
|
|
178
178
|
await self.base_client.__aexit__(exc_type, exc_val, exc_tb)
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
class RateLimitedHTTPXAsyncTransport(HTTPXAsyncTransport):
|
|
182
|
+
"""A wrapper around HTTPXAsyncTransport that applies rate limiting to GraphQL requests."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, base_transport: HTTPXAsyncTransport, rate_limit_config: RateLimitConfig):
|
|
185
|
+
# Copy all attributes from base transport, but exclude 'execute' to avoid shadowing our method
|
|
186
|
+
base_dict = {k: v for k, v in base_transport.__dict__.items() if k != "execute"}
|
|
187
|
+
self.__dict__.update(base_dict)
|
|
188
|
+
self.base_transport = base_transport
|
|
189
|
+
self.rate_limiter = RateLimiter[Callable[[], Any], Any](rate_limit_config)
|
|
190
|
+
|
|
191
|
+
async def connect(self):
|
|
192
|
+
"""Connect the underlying transport and replace its client with a rate-limited one."""
|
|
193
|
+
await self.base_transport.connect()
|
|
194
|
+
|
|
195
|
+
# Replace the base transport's client with a rate-limited version
|
|
196
|
+
if hasattr(self.base_transport, "client") and self.base_transport.client:
|
|
197
|
+
# The transport's client should be our AsyncClient type, but we need to handle the type
|
|
198
|
+
if isinstance(self.base_transport.client, AsyncClient):
|
|
199
|
+
self.base_transport.client = RateLimitedClient(
|
|
200
|
+
self.base_transport.client, self.rate_limiter.config
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Copy the client reference
|
|
204
|
+
self.client = self.base_transport.client
|
|
205
|
+
|
|
206
|
+
async def execute(
|
|
207
|
+
self,
|
|
208
|
+
document: DocumentNode,
|
|
209
|
+
variable_values: dict[str, Any] | None = None,
|
|
210
|
+
operation_name: str | None = None,
|
|
211
|
+
extra_args: dict[str, Any] | None = None,
|
|
212
|
+
upload_files: bool = False,
|
|
213
|
+
):
|
|
214
|
+
"""Execute a GraphQL request with rate limiting."""
|
|
215
|
+
|
|
216
|
+
async def request_func():
|
|
217
|
+
result = await self.base_transport.execute(
|
|
218
|
+
document=document,
|
|
219
|
+
variable_values=variable_values,
|
|
220
|
+
operation_name=operation_name,
|
|
221
|
+
extra_args=extra_args,
|
|
222
|
+
upload_files=upload_files,
|
|
223
|
+
)
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
# Use the rate limiter to execute the request
|
|
227
|
+
responses = await self.rate_limiter.execute_requests([request_func], lambda x: x())
|
|
228
|
+
if responses:
|
|
229
|
+
return responses[0]
|
|
230
|
+
|
|
231
|
+
raise ConnectorError(
|
|
232
|
+
message="No response from GraphQL API",
|
|
233
|
+
error_code=ErrorCode.API_ERROR,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_state(self) -> tuple[RateLimitConfig, float]:
|
|
237
|
+
"""Get the current rate limit state."""
|
|
238
|
+
return self.rate_limiter.config, self.rate_limiter.current_delay
|
|
239
|
+
|
|
240
|
+
async def close(self):
|
|
241
|
+
"""Close the underlying transport."""
|
|
242
|
+
if hasattr(self.base_transport, "close"):
|
|
243
|
+
await self.base_transport.close()
|
|
244
|
+
|
|
245
|
+
def __getattr__(self, name):
|
|
246
|
+
"""Delegate attribute access to the underlying base_transport."""
|
|
247
|
+
return getattr(self.base_transport, name)
|
|
248
|
+
|
|
249
|
+
|
|
181
250
|
class BaseIntegrationClient:
|
|
182
251
|
_http_client: AsyncClient | RateLimitedClient
|
|
183
252
|
_rate_limit_config: RateLimitConfig | None = None
|
|
@@ -248,8 +317,11 @@ class BaseIntegrationClient:
|
|
|
248
317
|
|
|
249
318
|
|
|
250
319
|
class BaseGraphQLSession(AsyncClientSession):
|
|
251
|
-
|
|
252
|
-
|
|
320
|
+
_rate_limit_config: RateLimitConfig | None = None
|
|
321
|
+
|
|
322
|
+
def __init__(self, args: Request, rate_limit_config: RateLimitConfig | None = None):
|
|
323
|
+
client = self.build_client(args, rate_limit_config)
|
|
324
|
+
super().__init__(client=client)
|
|
253
325
|
|
|
254
326
|
async def __aenter__(self) -> Self:
|
|
255
327
|
await self.client.__aenter__()
|
|
@@ -267,8 +339,31 @@ class BaseGraphQLSession(AsyncClientSession):
|
|
|
267
339
|
pass
|
|
268
340
|
|
|
269
341
|
@classmethod
|
|
270
|
-
def build_client(
|
|
271
|
-
|
|
342
|
+
def build_client(
|
|
343
|
+
cls, args: Request, rate_limit_config: RateLimitConfig | None = None
|
|
344
|
+
) -> Client:
|
|
345
|
+
client_args = cls.prepare_client_args(args)
|
|
346
|
+
|
|
347
|
+
# Apply rate limiting if configured
|
|
348
|
+
rate_limiting = rate_limit_config or cls._rate_limit_config
|
|
349
|
+
if rate_limiting is not None and "transport" in client_args:
|
|
350
|
+
transport = client_args["transport"]
|
|
351
|
+
if isinstance(transport, HTTPXAsyncTransport):
|
|
352
|
+
client_args["transport"] = RateLimitedHTTPXAsyncTransport(transport, rate_limiting)
|
|
353
|
+
|
|
354
|
+
return Client(**client_args)
|
|
355
|
+
|
|
356
|
+
def get_current_rate_limits(self) -> tuple[RateLimitConfig | None, float]:
|
|
357
|
+
"""
|
|
358
|
+
Get the current rate limit state.
|
|
359
|
+
|
|
360
|
+
Returns a tuple of the rate limit config and the current delay. (or None if the client is not rate limited)
|
|
361
|
+
"""
|
|
362
|
+
if hasattr(self.client, "transport") and isinstance(
|
|
363
|
+
self.client.transport, RateLimitedHTTPXAsyncTransport
|
|
364
|
+
):
|
|
365
|
+
return self.client.transport.get_state()
|
|
366
|
+
return None, 0
|
|
272
367
|
|
|
273
368
|
@classmethod
|
|
274
369
|
def load_schema(cls, schema_file_path: str | Path) -> GraphQLSchema:
|
|
@@ -8,7 +8,6 @@ from typing import Any, ClassVar, Generic, Literal, TypeVar, cast
|
|
|
8
8
|
from connector_sdk_types.generated import (
|
|
9
9
|
AuthCredential,
|
|
10
10
|
BasicCredential,
|
|
11
|
-
CredentialConfig,
|
|
12
11
|
ErrorResponse,
|
|
13
12
|
JWTCredential,
|
|
14
13
|
KeyPairCredential,
|
|
@@ -20,8 +19,9 @@ from connector_sdk_types.generated import (
|
|
|
20
19
|
TokenCredential,
|
|
21
20
|
)
|
|
22
21
|
from connector_sdk_types.oai.capability import Request
|
|
23
|
-
from connector_sdk_types.oai.modules.
|
|
22
|
+
from connector_sdk_types.oai.modules.credentials_module_types import (
|
|
24
23
|
AuthSetting,
|
|
24
|
+
CredentialConfig,
|
|
25
25
|
EmptySettings,
|
|
26
26
|
OAuthConfig,
|
|
27
27
|
)
|
|
@@ -2,9 +2,12 @@ from collections.abc import Mapping, Sequence
|
|
|
2
2
|
from functools import partial
|
|
3
3
|
from typing import Generic, TypeVar
|
|
4
4
|
|
|
5
|
-
from connector_sdk_types.generated import CredentialConfig
|
|
6
5
|
from connector_sdk_types.oai.capability import Request
|
|
7
|
-
from connector_sdk_types.oai.modules.
|
|
6
|
+
from connector_sdk_types.oai.modules.credentials_module_types import (
|
|
7
|
+
AuthSetting,
|
|
8
|
+
CredentialConfig,
|
|
9
|
+
OAuthConfig,
|
|
10
|
+
)
|
|
8
11
|
from pydantic import BaseModel
|
|
9
12
|
|
|
10
13
|
from connector.oai.capability import CapabilityCallableProto, get_capability_annotations
|
connector/oai/capability.py
CHANGED
|
@@ -103,6 +103,8 @@ from connector_sdk_types.generated import (
|
|
|
103
103
|
UnassignEntitlementResponse,
|
|
104
104
|
UpdateAccountRequest,
|
|
105
105
|
UpdateAccountResponse,
|
|
106
|
+
ValidateCredentialConfigRequest,
|
|
107
|
+
ValidateCredentialConfigResponse,
|
|
106
108
|
ValidateCredentialsRequest,
|
|
107
109
|
ValidateCredentialsResponse,
|
|
108
110
|
)
|
|
@@ -829,4 +831,15 @@ _STANDARD_CAPABILITY_SIGNATURES: dict[StandardCapabilityName, CapabilitySignatur
|
|
|
829
831
|
envelope_type=UnassignApplicationEntitlementResponse, is_request=False
|
|
830
832
|
),
|
|
831
833
|
),
|
|
834
|
+
# Per-credential validation capabilities
|
|
835
|
+
StandardCapabilityName.VALIDATE_CREDENTIAL_CONFIG: CapabilitySignature(
|
|
836
|
+
input_payload=_payload_type_data(
|
|
837
|
+
envelope_type=ValidateCredentialConfigRequest,
|
|
838
|
+
is_request=True,
|
|
839
|
+
),
|
|
840
|
+
output_payload=_payload_type_data(
|
|
841
|
+
envelope_type=ValidateCredentialConfigResponse,
|
|
842
|
+
is_request=False,
|
|
843
|
+
),
|
|
844
|
+
),
|
|
832
845
|
}
|
connector/oai/integration.py
CHANGED
|
@@ -32,15 +32,14 @@ from functools import cached_property
|
|
|
32
32
|
|
|
33
33
|
from connector_sdk_types.generated import (
|
|
34
34
|
AppCategory,
|
|
35
|
-
AuthModel,
|
|
36
35
|
BasicCredential,
|
|
37
36
|
CapabilitySchema,
|
|
38
|
-
CredentialConfig,
|
|
39
37
|
EntitlementType,
|
|
40
38
|
Info,
|
|
41
39
|
InfoResponse,
|
|
42
40
|
JWTCredential,
|
|
43
41
|
KeyPairCredential,
|
|
42
|
+
OAuth1Credential,
|
|
44
43
|
OAuthClientCredential,
|
|
45
44
|
OAuthCredential,
|
|
46
45
|
ResourceType,
|
|
@@ -48,7 +47,14 @@ from connector_sdk_types.generated import (
|
|
|
48
47
|
StandardCapabilityName,
|
|
49
48
|
TokenCredential,
|
|
50
49
|
)
|
|
51
|
-
from connector_sdk_types.oai.modules.
|
|
50
|
+
from connector_sdk_types.oai.modules.credentials_module_types import (
|
|
51
|
+
AuthModel,
|
|
52
|
+
AuthSetting,
|
|
53
|
+
CredentialConfig,
|
|
54
|
+
CredentialsSettings,
|
|
55
|
+
EmptySettings,
|
|
56
|
+
OAuthConfig,
|
|
57
|
+
)
|
|
52
58
|
from pydantic import BaseModel
|
|
53
59
|
|
|
54
60
|
from connector.oai.capability import (
|
|
@@ -58,14 +64,15 @@ from connector.oai.capability import (
|
|
|
58
64
|
)
|
|
59
65
|
from connector.oai.errors import ErrorMap
|
|
60
66
|
from connector.oai.modules.base_module import BaseIntegrationModule
|
|
67
|
+
from connector.oai.modules.credentials_module import CredentialsModule
|
|
61
68
|
from connector.oai.modules.info_module import InfoModule
|
|
62
69
|
from connector.oai.modules.oauth_module import OAuthModule
|
|
63
|
-
from connector.oai.modules.oauth_module_types import
|
|
70
|
+
from connector.oai.modules.oauth_module_types import OAuthSettings
|
|
64
71
|
|
|
65
72
|
from .capabilities.errors import CapabilityError, CapabilityNotImplementedError
|
|
66
73
|
from .capabilities.factory import CapabilityExecutorFactory
|
|
67
74
|
|
|
68
|
-
AUTH_TYPE_MAP = {
|
|
75
|
+
AUTH_TYPE_MAP: dict[AuthModel, type[BaseModel]] = {
|
|
69
76
|
AuthModel.OAUTH: OAuthCredential,
|
|
70
77
|
AuthModel.OAUTH_CLIENT_CREDENTIALS: OAuthClientCredential,
|
|
71
78
|
AuthModel.BASIC: BasicCredential,
|
|
@@ -73,6 +80,7 @@ AUTH_TYPE_MAP = {
|
|
|
73
80
|
AuthModel.JWT: JWTCredential,
|
|
74
81
|
AuthModel.SERVICE_ACCOUNT: ServiceAccountCredential,
|
|
75
82
|
AuthModel.KEY_PAIR: KeyPairCredential,
|
|
83
|
+
AuthModel.OAUTH1: OAuth1Credential,
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
|
|
@@ -183,6 +191,7 @@ class Integration:
|
|
|
183
191
|
entitlement_types: list[EntitlementType] | None = None,
|
|
184
192
|
settings_model: type[BaseModel] | None = None,
|
|
185
193
|
oauth_settings: OAuthSettings | None = None,
|
|
194
|
+
credentials_settings: CredentialsSettings | None = None,
|
|
186
195
|
):
|
|
187
196
|
self.app_id = app_id.strip()
|
|
188
197
|
self.version = version
|
|
@@ -195,6 +204,7 @@ class Integration:
|
|
|
195
204
|
self.entitlement_types = entitlement_types or []
|
|
196
205
|
self.settings_model = settings_model or EmptySettings
|
|
197
206
|
self.oauth_settings = oauth_settings
|
|
207
|
+
self.credentials_settings = credentials_settings
|
|
198
208
|
|
|
199
209
|
if len(self.app_id) == 0:
|
|
200
210
|
raise InvalidAppIdError
|
|
@@ -219,6 +229,12 @@ class Integration:
|
|
|
219
229
|
# Attach the info module (app_info capability)
|
|
220
230
|
self.add_module(InfoModule())
|
|
221
231
|
|
|
232
|
+
# Attach a credentials module if credentials are provided
|
|
233
|
+
if self.credentials:
|
|
234
|
+
self.add_module(
|
|
235
|
+
CredentialsModule(settings=credentials_settings or CredentialsSettings.default())
|
|
236
|
+
)
|
|
237
|
+
|
|
222
238
|
def add_module(self, module: BaseIntegrationModule):
|
|
223
239
|
self.modules.append(module)
|
|
224
240
|
module.register(self)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable, Sequence
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
|
+
|
|
5
|
+
from connector_sdk_types.generated import (
|
|
6
|
+
Error,
|
|
7
|
+
ErrorCode,
|
|
8
|
+
ErrorResponse,
|
|
9
|
+
StandardCapabilityName,
|
|
10
|
+
ValidateCredentialConfigRequest,
|
|
11
|
+
ValidateCredentialConfigResponse,
|
|
12
|
+
ValidatedCredentialConfig,
|
|
13
|
+
)
|
|
14
|
+
from connector_sdk_types.oai.modules.credentials_module_types import (
|
|
15
|
+
CredentialConfig,
|
|
16
|
+
CredentialsSettings,
|
|
17
|
+
OAuthConfig,
|
|
18
|
+
ValidateCredentialConfigCallable,
|
|
19
|
+
)
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
from connector.oai.capabilities.errors import CapabilityExecutionError
|
|
23
|
+
from connector.oai.modules.base_module import BaseIntegrationModule
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from connector.oai.integration import Integration
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CredentialsModule(BaseIntegrationModule):
|
|
30
|
+
"""
|
|
31
|
+
Credentials module is responsible for handling the credentials for an Integration.
|
|
32
|
+
It can register the following capabilities:
|
|
33
|
+
- VALIDATE_CREDENTIAL_CONFIG
|
|
34
|
+
|
|
35
|
+
It can be configured with the following settings:
|
|
36
|
+
- register_validation_capability: bool - Flag that indicates whether the CredentialsModule should register the validate_credential_config capability. Set to `False` to skip registration and implement the capability manually.
|
|
37
|
+
|
|
38
|
+
You can also call the base_validation method to perform base validation on a credential config request.
|
|
39
|
+
|
|
40
|
+
Example usage when manually registering the capability:
|
|
41
|
+
@integration.register_capability(StandardCapabilityName.VALIDATE_CREDENTIAL_CONFIG)
|
|
42
|
+
async def validate_credential_config(
|
|
43
|
+
args: ValidateCredentialConfigRequest,
|
|
44
|
+
) -> ValidateCredentialConfigResponse:
|
|
45
|
+
# Use base validation
|
|
46
|
+
errors = CredentialsModule.base_validation(integration, args)
|
|
47
|
+
if errors:
|
|
48
|
+
return ValidateCredentialConfigResponse(response=ValidatedCredentialConfig(valid=False, errors=errors))
|
|
49
|
+
# Add custom validation logic here
|
|
50
|
+
# ...
|
|
51
|
+
return ValidateCredentialConfigResponse(response=ValidatedCredentialConfig(valid=True))
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
credentials: dict[str, CredentialConfig]
|
|
55
|
+
settings: CredentialsSettings
|
|
56
|
+
|
|
57
|
+
def __init__(self, settings: CredentialsSettings):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.credentials = {}
|
|
60
|
+
self.settings = settings
|
|
61
|
+
|
|
62
|
+
def add_capability(self, capability: str):
|
|
63
|
+
"""Add a capability to the module."""
|
|
64
|
+
self.capabilities.append(capability)
|
|
65
|
+
|
|
66
|
+
def get_capability(self, capability: str) -> StandardCapabilityName | str | None:
|
|
67
|
+
"""Get a capability from the module."""
|
|
68
|
+
for cap in self.capabilities:
|
|
69
|
+
if cap == capability:
|
|
70
|
+
return cap
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def register(self, integration: "Integration"):
|
|
74
|
+
"""Register validate_credential_config capability for each credential config."""
|
|
75
|
+
self.integration = integration
|
|
76
|
+
validation_functions: dict[str, ValidateCredentialConfigCallable] = {}
|
|
77
|
+
|
|
78
|
+
for credential in integration.credentials:
|
|
79
|
+
self.credentials[credential.id] = credential
|
|
80
|
+
validation_implementation = credential.validation
|
|
81
|
+
if validation_implementation:
|
|
82
|
+
validation_functions[credential.id] = cast(
|
|
83
|
+
ValidateCredentialConfigCallable, validation_implementation
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if self.settings.register_validation_capability and (
|
|
87
|
+
StandardCapabilityName.VALIDATE_CREDENTIAL_CONFIG.value
|
|
88
|
+
not in self.integration.capabilities.keys()
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
If the Integration already registers a validate_credential_config capability,
|
|
92
|
+
we don't register another one, as it would be a duplicate.
|
|
93
|
+
"""
|
|
94
|
+
self.register_dynamic_validation_capability(
|
|
95
|
+
StandardCapabilityName.VALIDATE_CREDENTIAL_CONFIG,
|
|
96
|
+
validation_functions,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def register_dynamic_validation_capability(
|
|
100
|
+
self,
|
|
101
|
+
capability_name: StandardCapabilityName,
|
|
102
|
+
validation_functions: dict[str, ValidateCredentialConfigCallable],
|
|
103
|
+
):
|
|
104
|
+
@self.integration.register_capability(capability_name)
|
|
105
|
+
async def validate_credential_config(
|
|
106
|
+
args: ValidateCredentialConfigRequest,
|
|
107
|
+
) -> ValidateCredentialConfigResponse:
|
|
108
|
+
is_valid = False
|
|
109
|
+
errors = CredentialsModule.base_validation(
|
|
110
|
+
self.integration.app_id, self.integration.credentials, args
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if errors:
|
|
114
|
+
return ValidateCredentialConfigResponse(
|
|
115
|
+
response=ValidatedCredentialConfig(
|
|
116
|
+
valid=is_valid,
|
|
117
|
+
validation_errors=errors,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for credential_id, validation_function in validation_functions.items():
|
|
122
|
+
if credential_id != args.request.credential.id:
|
|
123
|
+
# Skip validation for non-received credentials
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
If this raises, it will bubble up just like any other capability error.
|
|
128
|
+
"""
|
|
129
|
+
result = validation_function(args)
|
|
130
|
+
if inspect.isawaitable(result):
|
|
131
|
+
result = await cast(
|
|
132
|
+
Awaitable[ValidateCredentialConfigResponse | ErrorResponse],
|
|
133
|
+
result,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if isinstance(result, ErrorResponse):
|
|
137
|
+
raise CapabilityExecutionError(result)
|
|
138
|
+
|
|
139
|
+
if isinstance(result, ValidateCredentialConfigResponse):
|
|
140
|
+
if not result.response.valid or result.response.validation_errors:
|
|
141
|
+
errors.extend(result.response.validation_errors or [])
|
|
142
|
+
|
|
143
|
+
if not result.response.valid and not result.response.validation_errors:
|
|
144
|
+
errors.append("Credential is invalid, unexpected error occurred")
|
|
145
|
+
|
|
146
|
+
if not errors:
|
|
147
|
+
is_valid = True
|
|
148
|
+
|
|
149
|
+
return ValidateCredentialConfigResponse(
|
|
150
|
+
response=ValidatedCredentialConfig(
|
|
151
|
+
valid=is_valid,
|
|
152
|
+
validation_errors=errors,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return validate_credential_config
|
|
157
|
+
|
|
158
|
+
# Utility methods
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def base_validation(
|
|
162
|
+
cls,
|
|
163
|
+
app_id: str,
|
|
164
|
+
credentials: Sequence[CredentialConfig | OAuthConfig],
|
|
165
|
+
request: ValidateCredentialConfigRequest,
|
|
166
|
+
) -> list[str]:
|
|
167
|
+
"""
|
|
168
|
+
Perform base validation on a credential config request.
|
|
169
|
+
|
|
170
|
+
This method can be called as a class method to reuse the base validation logic
|
|
171
|
+
when manually implementing the validate_credential_config capability.
|
|
172
|
+
|
|
173
|
+
Example usage when manually registering the capability:
|
|
174
|
+
@integration.register_capability(StandardCapabilityName.VALIDATE_CREDENTIAL_CONFIG)
|
|
175
|
+
async def validate_credential_config(
|
|
176
|
+
args: ValidateCredentialConfigRequest,
|
|
177
|
+
) -> ValidateCredentialConfigResponse:
|
|
178
|
+
# Use base validation
|
|
179
|
+
errors = CredentialsModule.base_validation("app_id", CredentialConfigList, args)
|
|
180
|
+
if errors:
|
|
181
|
+
return ValidateCredentialConfigResponse(
|
|
182
|
+
response=ValidatedCredentialConfig(valid=False, validation_errors=errors)
|
|
183
|
+
)
|
|
184
|
+
# Add custom validation logic here
|
|
185
|
+
# ...
|
|
186
|
+
return ValidateCredentialConfigResponse(
|
|
187
|
+
response=ValidatedCredentialConfig(valid=True, validation_errors=[])
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
app_id: The App ID of the integration
|
|
192
|
+
credentials: The credentials (configs) to validate against
|
|
193
|
+
request: The validation request to validate
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A list of error messages (empty if validation passes)
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
CapabilityExecutionError: For critical validation failures (missing ID, invalid config, etc.)
|
|
200
|
+
"""
|
|
201
|
+
errors: list[str] = [] # Customer facing errors
|
|
202
|
+
credential_config_id = request.request.credential.id
|
|
203
|
+
credential = request.request.credential
|
|
204
|
+
credential_dict = credential.model_dump()
|
|
205
|
+
|
|
206
|
+
if not credential_config_id:
|
|
207
|
+
# This should not happen, and should in such case fail immediately
|
|
208
|
+
raise CapabilityExecutionError(
|
|
209
|
+
ErrorResponse(
|
|
210
|
+
is_error=True,
|
|
211
|
+
error=Error(
|
|
212
|
+
error_code=ErrorCode.BAD_REQUEST,
|
|
213
|
+
message="Received credential without an ID",
|
|
214
|
+
app_id=app_id,
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Build credentials dict from integration.credentials
|
|
220
|
+
credentials_dict = {cred.id: cred for cred in credentials}
|
|
221
|
+
credential_config = credentials_dict.get(credential_config_id)
|
|
222
|
+
|
|
223
|
+
if credential_config is None:
|
|
224
|
+
# Should fail fast
|
|
225
|
+
raise CapabilityExecutionError(
|
|
226
|
+
ErrorResponse(
|
|
227
|
+
is_error=True,
|
|
228
|
+
error=Error(
|
|
229
|
+
error_code=ErrorCode.BAD_REQUEST,
|
|
230
|
+
message=f"Missing credential configuration for ID {credential_config_id}",
|
|
231
|
+
app_id=app_id,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
from connector.oai.integration import AUTH_TYPE_MAP
|
|
238
|
+
|
|
239
|
+
credential_model = AUTH_TYPE_MAP[credential_config.type](
|
|
240
|
+
**credential_dict[credential_config.type]
|
|
241
|
+
)
|
|
242
|
+
connector_credential = credential_model.model_validate(credential_model)
|
|
243
|
+
except ValidationError as e:
|
|
244
|
+
raise CapabilityExecutionError(
|
|
245
|
+
ErrorResponse(
|
|
246
|
+
is_error=True,
|
|
247
|
+
error=Error(
|
|
248
|
+
error_code=ErrorCode.BAD_REQUEST,
|
|
249
|
+
message="Invalid or malformed credential received",
|
|
250
|
+
app_id=app_id,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
) from e
|
|
254
|
+
|
|
255
|
+
# Dump the model
|
|
256
|
+
credential_dict = connector_credential.model_dump(exclude_none=True)
|
|
257
|
+
|
|
258
|
+
# Check for empty models
|
|
259
|
+
if credential_dict == {}:
|
|
260
|
+
errors.append("Credential is missing required fields")
|
|
261
|
+
return errors
|
|
262
|
+
|
|
263
|
+
# Get the list of required fields from the model's JSON schema
|
|
264
|
+
model_schema = credential_model.model_json_schema()
|
|
265
|
+
required_fields = set(model_schema.get("required", []))
|
|
266
|
+
|
|
267
|
+
# Check for empty strings on required fields
|
|
268
|
+
for field_name, value in credential_dict.items():
|
|
269
|
+
if field_name in required_fields and value == "":
|
|
270
|
+
errors.append(
|
|
271
|
+
"A required field is empty, please provide a value before submitting again"
|
|
272
|
+
)
|
|
273
|
+
return errors
|
|
274
|
+
|
|
275
|
+
# Check for extra whitespace
|
|
276
|
+
if any(value.strip() != value for value in credential_dict.values()):
|
|
277
|
+
errors.append(
|
|
278
|
+
"Credential has extra whitespace, please remove it before submitting it again"
|
|
279
|
+
)
|
|
280
|
+
return errors
|
|
281
|
+
|
|
282
|
+
return errors
|