salesforce-data-customcode 6.1.0.dev1__py3-none-any.whl → 6.1.0.dev2__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.
- datacustomcode/__init__.py +0 -5
- datacustomcode/cli.py +4 -29
- datacustomcode/client.py +192 -211
- datacustomcode/config.py +0 -5
- datacustomcode/config.yaml +6 -0
- datacustomcode/constants.py +0 -8
- datacustomcode/deploy.py +23 -57
- datacustomcode/function/runtime.py +16 -0
- datacustomcode/io/reader/base.py +0 -42
- datacustomcode/io/writer/base.py +0 -33
- datacustomcode/named_credential/__init__.py +26 -0
- datacustomcode/named_credential/base.py +54 -0
- datacustomcode/named_credential/default.py +93 -0
- datacustomcode/named_credential/direct/__init__.py +19 -0
- datacustomcode/named_credential/direct/auth.py +63 -0
- datacustomcode/named_credential/direct/credentials.py +121 -0
- datacustomcode/named_credential/direct/transport.py +110 -0
- datacustomcode/named_credential/direct/url_resolver.py +112 -0
- datacustomcode/named_credential/spark_base.py +93 -0
- datacustomcode/named_credential/spark_default.py +154 -0
- datacustomcode/named_credential/types/__init__.py +14 -0
- datacustomcode/named_credential/types/http_method.py +29 -0
- datacustomcode/named_credential/types/http_request.py +63 -0
- datacustomcode/named_credential/types/http_request_builder.py +55 -0
- datacustomcode/named_credential/types/http_response.py +43 -0
- datacustomcode/named_credential/types/http_response_builder.py +24 -0
- datacustomcode/named_credential_config.py +105 -0
- datacustomcode/run.py +7 -11
- datacustomcode/scan.py +29 -164
- datacustomcode/template.py +1 -13
- datacustomcode/templates/function/example/chunking_with_external_callout/README.md +119 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/config.json +3 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py +161 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/external_callout_config.json +11 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/tests/test.json +16 -0
- {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/METADATA +3 -40
- {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/RECORD +40 -19
- datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +0 -49
- {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/WHEEL +0 -0
- {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/licenses/LICENSE.txt +0 -0
datacustomcode/deploy.py
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import copy
|
|
18
17
|
from html import unescape
|
|
19
18
|
import json
|
|
20
19
|
import os
|
|
@@ -38,11 +37,13 @@ import requests
|
|
|
38
37
|
|
|
39
38
|
from datacustomcode.cmd import cmd_output
|
|
40
39
|
from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
|
|
40
|
+
from datacustomcode.named_credential.direct.credentials import (
|
|
41
|
+
EXTERNAL_CALLOUT_CREDENTIAL,
|
|
42
|
+
)
|
|
41
43
|
from datacustomcode.scan import find_base_directory, get_package_type
|
|
42
44
|
|
|
43
45
|
DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
|
|
44
46
|
DATA_TRANSFORMS_PATH = "services/data/v63.0/ssot/data-transforms"
|
|
45
|
-
DATA_CUSTOM_CODE_INVOKE_OPTIONS_PATH = "services/data/v67.0/ssot/data-custom-code"
|
|
46
47
|
WAIT_FOR_DEPLOYMENT_TIMEOUT = 3000
|
|
47
48
|
|
|
48
49
|
# Available compute types for Data Cloud deployments.
|
|
@@ -110,7 +111,6 @@ class CodeExtensionMetadata(BaseModel):
|
|
|
110
111
|
computeType: str
|
|
111
112
|
codeType: str
|
|
112
113
|
functionInvokeOptions: Union[list[str], None] = None
|
|
113
|
-
invokeOptions: Union[list[str], None] = None
|
|
114
114
|
|
|
115
115
|
def __init__(self, **data):
|
|
116
116
|
name = data.get("name", "")
|
|
@@ -203,14 +203,7 @@ def create_deployment(
|
|
|
203
203
|
access_token: AccessTokenResponse, metadata: CodeExtensionMetadata
|
|
204
204
|
) -> CreateDeploymentResponse:
|
|
205
205
|
"""Create a custom code deployment in the DataCloud."""
|
|
206
|
-
|
|
207
|
-
# option isn't silently dropped. Everything else stays on v63.0.
|
|
208
|
-
code_custom_code_path = (
|
|
209
|
-
DATA_CUSTOM_CODE_INVOKE_OPTIONS_PATH
|
|
210
|
-
if metadata.invokeOptions
|
|
211
|
-
else DATA_CUSTOM_CODE_PATH
|
|
212
|
-
)
|
|
213
|
-
url = _join_strip_url(access_token.instance_url, code_custom_code_path)
|
|
206
|
+
url = _join_strip_url(access_token.instance_url, DATA_CUSTOM_CODE_PATH)
|
|
214
207
|
body = dict[str, Any](
|
|
215
208
|
{
|
|
216
209
|
"label": metadata.name,
|
|
@@ -223,8 +216,6 @@ def create_deployment(
|
|
|
223
216
|
)
|
|
224
217
|
if metadata.functionInvokeOptions:
|
|
225
218
|
body["functionInvokeOptions"] = metadata.functionInvokeOptions
|
|
226
|
-
if metadata.invokeOptions:
|
|
227
|
-
body["invokeOptions"] = metadata.invokeOptions
|
|
228
219
|
logger.debug(f"Creating deployment {metadata.name}...")
|
|
229
220
|
try:
|
|
230
221
|
response = _make_api_call(
|
|
@@ -248,6 +239,8 @@ DEPENDENCIES_ARCHIVE_PATH = os.path.join(
|
|
|
248
239
|
)
|
|
249
240
|
PY_FILES_PATH = os.path.join("payload", "py-files")
|
|
250
241
|
ZIP_FILE_NAME = "deployment.zip"
|
|
242
|
+
# Local-only files that must never be packaged into the deployment zip.
|
|
243
|
+
EXCLUDED_FILES = (".DS_Store", EXTERNAL_CALLOUT_CREDENTIAL)
|
|
251
244
|
|
|
252
245
|
|
|
253
246
|
def prepare_dependency_archive(
|
|
@@ -400,30 +393,6 @@ class DataTransformConfig(BaseConfig):
|
|
|
400
393
|
dataspace: str
|
|
401
394
|
permissions: Permissions
|
|
402
395
|
dataObjects: Optional[list[DataObject]] = None
|
|
403
|
-
streamingSource: Optional[StreamingSource] = None
|
|
404
|
-
|
|
405
|
-
@property
|
|
406
|
-
def is_streaming(self) -> bool:
|
|
407
|
-
return self.streamingSource is not None
|
|
408
|
-
|
|
409
|
-
@model_validator(mode="after")
|
|
410
|
-
def _validate_layers(self) -> "DataTransformConfig":
|
|
411
|
-
read_is_dlo = isinstance(self.permissions.read, DloPermission)
|
|
412
|
-
write_is_dlo = isinstance(self.permissions.write, DloPermission)
|
|
413
|
-
if self.is_streaming:
|
|
414
|
-
if not write_is_dlo:
|
|
415
|
-
raise ValueError(
|
|
416
|
-
"A streaming transform must write to a DLO "
|
|
417
|
-
"(permissions.write must be a 'dlo' entry)."
|
|
418
|
-
)
|
|
419
|
-
elif read_is_dlo != write_is_dlo:
|
|
420
|
-
raise ValueError(
|
|
421
|
-
"permissions.read and permissions.write must both reference "
|
|
422
|
-
"DLOs or both reference DMOs (got "
|
|
423
|
-
f"read={type(self.permissions.read).__name__}, "
|
|
424
|
-
f"write={type(self.permissions.write).__name__})"
|
|
425
|
-
)
|
|
426
|
-
return self
|
|
427
396
|
|
|
428
397
|
|
|
429
398
|
class FunctionConfig(BaseConfig):
|
|
@@ -438,15 +407,23 @@ class DmoPermission(BaseModel):
|
|
|
438
407
|
dmo: list[str]
|
|
439
408
|
|
|
440
409
|
|
|
441
|
-
class StreamingSource(BaseModel):
|
|
442
|
-
type: str
|
|
443
|
-
name: str
|
|
444
|
-
|
|
445
|
-
|
|
446
410
|
class Permissions(BaseModel):
|
|
447
411
|
read: Union[DloPermission, DmoPermission]
|
|
448
412
|
write: Union[DloPermission, DmoPermission]
|
|
449
413
|
|
|
414
|
+
@model_validator(mode="after")
|
|
415
|
+
def _no_mixed_layers(self) -> "Permissions":
|
|
416
|
+
read_is_dlo = isinstance(self.read, DloPermission)
|
|
417
|
+
write_is_dlo = isinstance(self.write, DloPermission)
|
|
418
|
+
if read_is_dlo != write_is_dlo:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
"permissions.read and permissions.write must both reference "
|
|
421
|
+
"DLOs or both reference DMOs (got "
|
|
422
|
+
f"read={type(self.read).__name__}, "
|
|
423
|
+
f"write={type(self.write).__name__})"
|
|
424
|
+
)
|
|
425
|
+
return self
|
|
426
|
+
|
|
450
427
|
|
|
451
428
|
def _permission_entries(perm: Union[DloPermission, DmoPermission]) -> list[str]:
|
|
452
429
|
"""Return the list of object names regardless of layer (DLO or DMO)."""
|
|
@@ -518,9 +495,7 @@ def create_data_transform(
|
|
|
518
495
|
) -> dict:
|
|
519
496
|
"""Create a data transform in the DataCloud."""
|
|
520
497
|
script_name = metadata.name
|
|
521
|
-
|
|
522
|
-
# otherwise be shared across calls and accumulate entries between deploys.
|
|
523
|
-
request_hydrated = copy.deepcopy(DATA_TRANSFORM_REQUEST_TEMPLATE)
|
|
498
|
+
request_hydrated = DATA_TRANSFORM_REQUEST_TEMPLATE.copy()
|
|
524
499
|
|
|
525
500
|
# Add nodes for each write entry (DLO or DMO)
|
|
526
501
|
for i, name in enumerate(
|
|
@@ -563,7 +538,7 @@ def create_data_transform(
|
|
|
563
538
|
"definition": definition,
|
|
564
539
|
"label": f"{metadata.name}",
|
|
565
540
|
"name": f"{metadata.name}",
|
|
566
|
-
"type": "
|
|
541
|
+
"type": "BATCH",
|
|
567
542
|
"dataSpaceName": data_transform_config.dataspace,
|
|
568
543
|
}
|
|
569
544
|
|
|
@@ -628,9 +603,9 @@ def zip(
|
|
|
628
603
|
|
|
629
604
|
with zipfile.ZipFile(ZIP_FILE_NAME, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
630
605
|
for root, dirs, files in os.walk(directory):
|
|
631
|
-
# Skip .DS_Store
|
|
606
|
+
# Skip .DS_Store and local credentials.
|
|
632
607
|
for file in files:
|
|
633
|
-
if file
|
|
608
|
+
if file not in EXCLUDED_FILES:
|
|
634
609
|
abs_path = os.path.join(root, file)
|
|
635
610
|
arcname = os.path.relpath(abs_path, directory)
|
|
636
611
|
zipf.write(abs_path, arcname)
|
|
@@ -646,18 +621,9 @@ def deploy_full(
|
|
|
646
621
|
callback=None,
|
|
647
622
|
) -> AccessTokenResponse:
|
|
648
623
|
"""Deploy a data transform in the DataCloud."""
|
|
649
|
-
from datacustomcode.constants import SCRIPT_USE_IN_FEATURE_STREAMING
|
|
650
|
-
|
|
651
624
|
# prepare payload
|
|
652
625
|
config = get_config(directory)
|
|
653
626
|
|
|
654
|
-
if (
|
|
655
|
-
isinstance(config, DataTransformConfig)
|
|
656
|
-
and config.is_streaming
|
|
657
|
-
and not metadata.invokeOptions
|
|
658
|
-
):
|
|
659
|
-
metadata.invokeOptions = [SCRIPT_USE_IN_FEATURE_STREAMING]
|
|
660
|
-
|
|
661
627
|
# create deployment and upload payload
|
|
662
628
|
deployment = create_deployment(access_token, metadata)
|
|
663
629
|
zip(directory, docker_network, metadata.codeType)
|
|
@@ -23,6 +23,8 @@ from datacustomcode.file.path.default import DefaultFindFilePath
|
|
|
23
23
|
from datacustomcode.function.base import BaseRuntime
|
|
24
24
|
from datacustomcode.llm_gateway.base import LLMGateway
|
|
25
25
|
from datacustomcode.llm_gateway_config import llm_gateway_config
|
|
26
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
27
|
+
from datacustomcode.named_credential_config import named_credential_config
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class Runtime(BaseRuntime):
|
|
@@ -69,6 +71,7 @@ class Runtime(BaseRuntime):
|
|
|
69
71
|
self._llm_gateway: Optional[LLMGateway] = None
|
|
70
72
|
self._file = DefaultFindFilePath()
|
|
71
73
|
self._einstein_predictions: Optional[EinsteinPredictions] = None
|
|
74
|
+
self._named_credential: Optional[NamedCredential] = None
|
|
72
75
|
|
|
73
76
|
@property
|
|
74
77
|
def llm_gateway(self) -> LLMGateway:
|
|
@@ -98,3 +101,16 @@ class Runtime(BaseRuntime):
|
|
|
98
101
|
einstein_predictions_config.einstein_predictions_config.to_object()
|
|
99
102
|
)
|
|
100
103
|
return self._einstein_predictions
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def named_credential(self) -> NamedCredential:
|
|
107
|
+
if self._named_credential is None:
|
|
108
|
+
if named_credential_config.named_credential_config is None:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Named Credential is not configured. Add "
|
|
111
|
+
"'named_credential_config' section to config.yaml"
|
|
112
|
+
)
|
|
113
|
+
self._named_credential = (
|
|
114
|
+
named_credential_config.named_credential_config.to_object()
|
|
115
|
+
)
|
|
116
|
+
return self._named_credential
|
datacustomcode/io/reader/base.py
CHANGED
|
@@ -41,45 +41,3 @@ class BaseDataCloudReader(BaseDataAccessLayer):
|
|
|
41
41
|
name: str,
|
|
42
42
|
schema: Union[AtomicType, StructType, str, None] = None,
|
|
43
43
|
) -> PySparkDataFrame: ...
|
|
44
|
-
|
|
45
|
-
def read_dlo_deltas(self) -> PySparkDataFrame:
|
|
46
|
-
"""Read the streaming change feed (deltas) for a Data Lake Object.
|
|
47
|
-
|
|
48
|
-
This is the streaming counterpart to :meth:`read_dlo`. It returns a
|
|
49
|
-
streaming DataFrame over the change feed the Data Cloud runtime
|
|
50
|
-
publishes for a streaming (``DELTA_SYNC``) transform. Concrete
|
|
51
|
-
streaming behavior is provided by the deployed Data Cloud runtime; the
|
|
52
|
-
base implementation raises :class:`NotImplementedError` so local
|
|
53
|
-
readers that do not support streaming fail clearly.
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
A streaming PySpark DataFrame over the DLO change feed.
|
|
57
|
-
|
|
58
|
-
Raises:
|
|
59
|
-
NotImplementedError: If the active reader does not support streaming
|
|
60
|
-
deltas (e.g. the local development readers).
|
|
61
|
-
"""
|
|
62
|
-
raise NotImplementedError(
|
|
63
|
-
"read_dlo_deltas is only supported when running in the Data Cloud "
|
|
64
|
-
"streaming runtime; the local reader does not support streaming "
|
|
65
|
-
"deltas."
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
def read_dmo_deltas(self) -> PySparkDataFrame:
|
|
69
|
-
"""Read the streaming change feed (deltas) for a Data Model Object.
|
|
70
|
-
|
|
71
|
-
Streaming counterpart to :meth:`read_dmo`. See :meth:`read_dlo_deltas`
|
|
72
|
-
for behavior and the local-development caveat.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
A streaming PySpark DataFrame over the DMO change feed.
|
|
76
|
-
|
|
77
|
-
Raises:
|
|
78
|
-
NotImplementedError: If the active reader does not support streaming
|
|
79
|
-
deltas (e.g. the local development readers).
|
|
80
|
-
"""
|
|
81
|
-
raise NotImplementedError(
|
|
82
|
-
"read_dmo_deltas is only supported when running in the Data Cloud "
|
|
83
|
-
"streaming runtime; the local reader does not support streaming "
|
|
84
|
-
"deltas."
|
|
85
|
-
)
|
datacustomcode/io/writer/base.py
CHANGED
|
@@ -22,7 +22,6 @@ from datacustomcode.io.base import BaseDataAccessLayer
|
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
24
|
from pyspark.sql import DataFrame as PySparkDataFrame, SparkSession
|
|
25
|
-
from pyspark.sql.streaming import StreamingQuery
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
class WriteMode(str, Enum):
|
|
@@ -58,35 +57,3 @@ class BaseDataCloudWriter(BaseDataAccessLayer):
|
|
|
58
57
|
def write_to_dmo(
|
|
59
58
|
self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
|
|
60
59
|
) -> None: ...
|
|
61
|
-
|
|
62
|
-
def write_dlo_deltas(
|
|
63
|
-
self, name: str, dataframe: PySparkDataFrame
|
|
64
|
-
) -> StreamingQuery:
|
|
65
|
-
"""Write a streaming DataFrame of deltas to a Data Lake Object.
|
|
66
|
-
|
|
67
|
-
Streaming counterpart to :meth:`write_to_dlo`. Starts a streaming query
|
|
68
|
-
that writes each micro-batch to the target DLO via the Data Cloud
|
|
69
|
-
streaming sink and returns the resulting ``StreamingQuery`` handle. The
|
|
70
|
-
runtime owns the trigger and checkpoint location; callers pass only the
|
|
71
|
-
table name. Concrete streaming behavior is provided by the deployed
|
|
72
|
-
Data Cloud runtime; the base implementation raises
|
|
73
|
-
:class:`NotImplementedError`.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
name: Target Data Lake Object name.
|
|
77
|
-
dataframe: Streaming PySpark DataFrame produced from a
|
|
78
|
-
``read_dlo_deltas`` / ``read_dmo_deltas`` source.
|
|
79
|
-
|
|
80
|
-
Returns:
|
|
81
|
-
The started ``StreamingQuery``; the caller drives its lifecycle
|
|
82
|
-
(typically ``query.awaitTermination()``).
|
|
83
|
-
|
|
84
|
-
Raises:
|
|
85
|
-
NotImplementedError: If the active writer does not support streaming
|
|
86
|
-
deltas (e.g. the local development writers).
|
|
87
|
-
"""
|
|
88
|
-
raise NotImplementedError(
|
|
89
|
-
"write_dlo_deltas is only supported when running in the Data Cloud "
|
|
90
|
-
"streaming runtime; the local writer does not support streaming "
|
|
91
|
-
"deltas."
|
|
92
|
-
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
17
|
+
from datacustomcode.named_credential.default import DefaultNamedCredential
|
|
18
|
+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
|
|
19
|
+
from datacustomcode.named_credential.spark_default import DefaultSparkNamedCredential
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DefaultNamedCredential",
|
|
23
|
+
"DefaultSparkNamedCredential",
|
|
24
|
+
"NamedCredential",
|
|
25
|
+
"SparkNamedCredential",
|
|
26
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import TYPE_CHECKING, Optional
|
|
19
|
+
|
|
20
|
+
from datacustomcode.mixin import UserExtendableNamedConfigMixin
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
24
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NamedCredential(ABC, UserExtendableNamedConfigMixin):
|
|
28
|
+
CONFIG_NAME: str
|
|
29
|
+
|
|
30
|
+
def __init__(self, **kwargs):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def request(
|
|
35
|
+
self,
|
|
36
|
+
request: HTTPRequest,
|
|
37
|
+
body: Optional[str] = None,
|
|
38
|
+
) -> HTTPResponse:
|
|
39
|
+
"""Make an external callout through a Named Credential.
|
|
40
|
+
|
|
41
|
+
The endpoint and its authentication are resolved server-side from the
|
|
42
|
+
Named Credential referenced by ``request.url``; the function never sees
|
|
43
|
+
the external credential.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request: The callout request
|
|
47
|
+
body: Optional request body. Set the
|
|
48
|
+
``Content-Type`` header to match the format; the SDK
|
|
49
|
+
does not assume or inject one.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The external service's response.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
from typing import (
|
|
17
|
+
Any,
|
|
18
|
+
Dict,
|
|
19
|
+
Optional,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
23
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
24
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
25
|
+
from datacustomcode.named_credential.types.http_response_builder import (
|
|
26
|
+
HTTPResponseBuilder,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DefaultNamedCredential(NamedCredential):
|
|
31
|
+
"""
|
|
32
|
+
Executes the callout directly via :class:`DirectCalloutTransport`, resolving
|
|
33
|
+
the URL from the Named Credential Connect API (falling back to
|
|
34
|
+
``external_callout_config.json``) and injecting auth from the same file.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
CONFIG_NAME = "DefaultNamedCredential"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
credentials_profile: str = "default",
|
|
42
|
+
sf_cli_org: Optional[str] = None,
|
|
43
|
+
credential_file: Optional[str] = None,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self._credentials_profile = credentials_profile
|
|
48
|
+
self._sf_cli_org = sf_cli_org
|
|
49
|
+
self._credential_file = credential_file
|
|
50
|
+
self._transport: Optional[Any] = None
|
|
51
|
+
|
|
52
|
+
def request(
|
|
53
|
+
self,
|
|
54
|
+
request: HTTPRequest,
|
|
55
|
+
body: Optional[str] = None,
|
|
56
|
+
) -> HTTPResponse:
|
|
57
|
+
# Body and response are treated as opaque strings; the SDK makes no
|
|
58
|
+
# assumption about their format (JSON, XML, text, ...).
|
|
59
|
+
callout_request = {
|
|
60
|
+
"path": request.url,
|
|
61
|
+
"method": request.method,
|
|
62
|
+
"headers": dict(request.headers),
|
|
63
|
+
"body": body if body is not None else "",
|
|
64
|
+
}
|
|
65
|
+
callout_response = self._callout(callout_request)
|
|
66
|
+
return HTTPResponseBuilder.build(
|
|
67
|
+
{
|
|
68
|
+
"status_code": callout_response.get("status_code"),
|
|
69
|
+
"headers": callout_response.get("headers", {}),
|
|
70
|
+
"body": callout_response.get("body") or "",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _callout(self, callout_request: Dict[str, Any]) -> Dict[str, Any]:
|
|
75
|
+
"""Execute the callout via the transport.
|
|
76
|
+
|
|
77
|
+
Returns a dict with ``status_code``, ``headers``, and ``body``.
|
|
78
|
+
"""
|
|
79
|
+
result: Dict[str, Any] = self._get_transport().callout(callout_request)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def _get_transport(self) -> Any:
|
|
83
|
+
if self._transport is None:
|
|
84
|
+
from datacustomcode.named_credential.direct.transport import (
|
|
85
|
+
DirectCalloutTransport,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._transport = DirectCalloutTransport(
|
|
89
|
+
credentials_profile=self._credentials_profile,
|
|
90
|
+
sf_cli_org=self._sf_cli_org,
|
|
91
|
+
credential_file=self._credential_file,
|
|
92
|
+
)
|
|
93
|
+
return self._transport
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Named Credential callout path.
|
|
16
|
+
|
|
17
|
+
This resolves the endpoint via the NamedCredential Connect API
|
|
18
|
+
and injects auth from a developer-provided ``external_callout_config.json``
|
|
19
|
+
"""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
Inject external credential auth into an outgoing request.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
from typing import (
|
|
23
|
+
TYPE_CHECKING,
|
|
24
|
+
Any,
|
|
25
|
+
Dict,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from requests.auth import AuthBase
|
|
29
|
+
|
|
30
|
+
from datacustomcode.named_credential.direct.credentials import AuthType
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from requests.models import PreparedRequest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DynamicAuthHandler(AuthBase):
|
|
37
|
+
def __init__(self, cred_config: Dict[str, Any]) -> None:
|
|
38
|
+
self.config = cred_config
|
|
39
|
+
self.auth_type = cred_config.get("auth_type")
|
|
40
|
+
|
|
41
|
+
def __call__(self, request: PreparedRequest) -> PreparedRequest:
|
|
42
|
+
if self.auth_type == AuthType.BASIC.value:
|
|
43
|
+
user = self.config.get("username", "")
|
|
44
|
+
pwd = self.config.get("password", "")
|
|
45
|
+
token = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
|
46
|
+
request.headers["Authorization"] = f"Basic {token}"
|
|
47
|
+
|
|
48
|
+
elif self.auth_type == AuthType.CUSTOM.value:
|
|
49
|
+
for name, value in self.config.get("custom_headers", {}).items():
|
|
50
|
+
request.headers[name] = value
|
|
51
|
+
|
|
52
|
+
elif self.auth_type in (AuthType.OAUTH.value, AuthType.JWT.value):
|
|
53
|
+
bearer = self.config.get("access_token") or self.config.get("token")
|
|
54
|
+
if not bearer:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"'{self.auth_type}' auth requires an 'access_token' or 'token'."
|
|
57
|
+
)
|
|
58
|
+
request.headers["Authorization"] = f"Bearer {bearer}"
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"Unsupported auth_type '{self.auth_type}'.")
|
|
62
|
+
|
|
63
|
+
return request
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Load developer credentials for direct callouts from ``external_callout_config.json``.
|
|
16
|
+
|
|
17
|
+
The file lives in the parent of the payload folder so it is never packaged in
|
|
18
|
+
the deployment zip. Its ``credentials`` section is keyed by callout reference
|
|
19
|
+
(``callout:<NamedCredential>``); each value carries a mandatory ``auth_type``
|
|
20
|
+
and an optional ``target_url``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from enum import Enum
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import (
|
|
30
|
+
Any,
|
|
31
|
+
Dict,
|
|
32
|
+
Optional,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Default file name; discovered in the parent of the payload folder.
|
|
36
|
+
EXTERNAL_CALLOUT_CREDENTIAL = "external_callout_config.json"
|
|
37
|
+
# Absolute-path override, primarily for tests and non-standard layouts.
|
|
38
|
+
CREDENTIAL_FILE_ENV_VAR = "DATACUSTOMCODE_EXTERNAL_CALLOUT_CONFIG"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthType(str, Enum):
|
|
42
|
+
"""External Credential auth types supported by External Services."""
|
|
43
|
+
|
|
44
|
+
BASIC = "Basic"
|
|
45
|
+
CUSTOM = "Custom"
|
|
46
|
+
JWT = "Jwt"
|
|
47
|
+
OAUTH = "OAuth"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CredentialError(RuntimeError):
|
|
51
|
+
"""Raised when credentials cannot be found or are invalid."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _discover_credential_file() -> Optional[Path]:
|
|
55
|
+
"""Find the config file via env override, then by walking up from cwd."""
|
|
56
|
+
override = os.environ.get(CREDENTIAL_FILE_ENV_VAR)
|
|
57
|
+
if override:
|
|
58
|
+
return Path(override)
|
|
59
|
+
|
|
60
|
+
for directory in (Path.cwd(), *Path.cwd().parents):
|
|
61
|
+
candidate = directory / EXTERNAL_CALLOUT_CREDENTIAL
|
|
62
|
+
if candidate.is_file():
|
|
63
|
+
return candidate
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CredentialStore:
|
|
68
|
+
"""Reads ``external_callout_config.json`` and returns per-callout config."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, credential_file: Optional[str] = None) -> None:
|
|
71
|
+
self._explicit_path = Path(credential_file) if credential_file else None
|
|
72
|
+
self._credentials: Optional[Dict[str, Dict[str, Any]]] = None
|
|
73
|
+
|
|
74
|
+
def _load(self) -> Dict[str, Dict[str, Any]]:
|
|
75
|
+
if self._credentials is not None:
|
|
76
|
+
return self._credentials
|
|
77
|
+
|
|
78
|
+
path = self._explicit_path or _discover_credential_file()
|
|
79
|
+
if path is None or not path.is_file():
|
|
80
|
+
raise CredentialError(
|
|
81
|
+
f"Could not find '{EXTERNAL_CALLOUT_CREDENTIAL}'. Place it in the "
|
|
82
|
+
f"parent of your payload folder, or set "
|
|
83
|
+
f"${CREDENTIAL_FILE_ENV_VAR} to its path."
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
with open(path, "r") as f:
|
|
87
|
+
data = json.load(f)
|
|
88
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
89
|
+
raise CredentialError(f"Failed to read '{path}': {exc}") from exc
|
|
90
|
+
|
|
91
|
+
# Per-callout entries live under the ``credentials`` section, leaving
|
|
92
|
+
# room for other config sections alongside them in the future.
|
|
93
|
+
credentials = data.get("credentials") if isinstance(data, dict) else None
|
|
94
|
+
if not isinstance(credentials, dict):
|
|
95
|
+
raise CredentialError(
|
|
96
|
+
f"'{path}' must be a JSON object with a 'credentials' section "
|
|
97
|
+
f"keyed by callout reference."
|
|
98
|
+
)
|
|
99
|
+
self._credentials = credentials
|
|
100
|
+
return credentials
|
|
101
|
+
|
|
102
|
+
def get(self, callout_key: str) -> Dict[str, Any]:
|
|
103
|
+
"""Return the config for a callout key (e.g. ``callout:AWS_S3_Service``).
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
CredentialError: if the key is missing or has no ``auth_type``.
|
|
107
|
+
"""
|
|
108
|
+
credentials = self._load()
|
|
109
|
+
config = credentials.get(callout_key)
|
|
110
|
+
if config is None:
|
|
111
|
+
raise CredentialError(
|
|
112
|
+
f"No credential configuration found for '{callout_key}'. "
|
|
113
|
+
f"Add it to the 'credentials' section of "
|
|
114
|
+
f"'{EXTERNAL_CALLOUT_CREDENTIAL}'."
|
|
115
|
+
)
|
|
116
|
+
if not isinstance(config, dict) or not config.get("auth_type"):
|
|
117
|
+
raise CredentialError(
|
|
118
|
+
f"Credential for '{callout_key}' is missing the mandatory "
|
|
119
|
+
f"'auth_type' field."
|
|
120
|
+
)
|
|
121
|
+
return config
|