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
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
"""Send a Named Credential callout over HTTP.
|
|
16
|
+
|
|
17
|
+
Resolves the ``callout:<NamedCredential>/<path>`` reference to a real endpoint
|
|
18
|
+
(via the Named Credential Connect API, falling back to ``target_url`` in
|
|
19
|
+
``external_callout_config.json``), attaches the credential's auth, and returns the raw
|
|
20
|
+
``{status_code, headers, body}`` response.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import (
|
|
26
|
+
Any,
|
|
27
|
+
Dict,
|
|
28
|
+
Optional,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
from datacustomcode.named_credential.direct.auth import DynamicAuthHandler
|
|
34
|
+
from datacustomcode.named_credential.direct.credentials import (
|
|
35
|
+
CredentialError,
|
|
36
|
+
CredentialStore,
|
|
37
|
+
)
|
|
38
|
+
from datacustomcode.named_credential.direct.url_resolver import resolve_base_url
|
|
39
|
+
from datacustomcode.token_provider import (
|
|
40
|
+
CredentialsTokenProvider,
|
|
41
|
+
SFCLITokenProvider,
|
|
42
|
+
TokenProvider,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DirectCalloutTransport:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
credentials_profile: str = "default",
|
|
50
|
+
sf_cli_org: Optional[str] = None,
|
|
51
|
+
credential_file: Optional[str] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._store = CredentialStore(credential_file)
|
|
54
|
+
self._token_provider = self._build_token_provider(
|
|
55
|
+
credentials_profile, sf_cli_org
|
|
56
|
+
)
|
|
57
|
+
# Resolved base URL per callout key. Stable for the transport's life, so
|
|
58
|
+
# cache it to avoid a token fetch + Connect API call on every row
|
|
59
|
+
self._base_url_cache: Dict[str, str] = {}
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _build_token_provider(
|
|
63
|
+
credentials_profile: str, sf_cli_org: Optional[str]
|
|
64
|
+
) -> TokenProvider:
|
|
65
|
+
if sf_cli_org:
|
|
66
|
+
return SFCLITokenProvider(sf_cli_org)
|
|
67
|
+
return CredentialsTokenProvider(credentials_profile)
|
|
68
|
+
|
|
69
|
+
def callout(self, callout_request: Dict[str, Any]) -> Dict[str, Any]:
|
|
70
|
+
raw_url = callout_request["path"]
|
|
71
|
+
if not raw_url.startswith("callout:"):
|
|
72
|
+
raise CredentialError(
|
|
73
|
+
f"Callout URL must start with 'callout:', got '{raw_url}'."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Split the named credential reference at the first '/' or '?'; the remainder
|
|
77
|
+
# path or query string is appended to the resolved base URL verbatim.
|
|
78
|
+
sep_idx = min(
|
|
79
|
+
(i for i in (raw_url.find("/"), raw_url.find("?")) if i != -1),
|
|
80
|
+
default=len(raw_url),
|
|
81
|
+
)
|
|
82
|
+
callout_key = raw_url[:sep_idx]
|
|
83
|
+
path_suffix = raw_url[sep_idx:]
|
|
84
|
+
|
|
85
|
+
if callout_key == "callout:":
|
|
86
|
+
raise CredentialError(f"Named Credential name is empty in URL '{raw_url}'.")
|
|
87
|
+
|
|
88
|
+
cred_config = self._store.get(callout_key)
|
|
89
|
+
base_url = self._base_url_cache.get(callout_key)
|
|
90
|
+
if base_url is None:
|
|
91
|
+
base_url = resolve_base_url(callout_key, cred_config, self._token_provider)
|
|
92
|
+
self._base_url_cache[callout_key] = base_url
|
|
93
|
+
|
|
94
|
+
body = callout_request.get("body") or None
|
|
95
|
+
# Headers are passed; the SDK assumes no Content-Type.
|
|
96
|
+
headers = dict(callout_request.get("headers", {}))
|
|
97
|
+
|
|
98
|
+
response = requests.request(
|
|
99
|
+
method=callout_request["method"],
|
|
100
|
+
url=base_url + path_suffix,
|
|
101
|
+
headers=headers,
|
|
102
|
+
data=body,
|
|
103
|
+
auth=DynamicAuthHandler(cred_config),
|
|
104
|
+
timeout=30,
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
"status_code": response.status_code,
|
|
108
|
+
"headers": dict(response.headers),
|
|
109
|
+
"body": response.text,
|
|
110
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
"""Resolve a ``callout:<NamedCredential>`` reference to a real base URL.
|
|
16
|
+
|
|
17
|
+
Prefers the NamedCredential Connect API ``calloutUrl``; falls back to the
|
|
18
|
+
developer-supplied ``target_url`` when the API is unavailable or the Named
|
|
19
|
+
Credential is not yet onboarded.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from typing import (
|
|
26
|
+
TYPE_CHECKING,
|
|
27
|
+
Any,
|
|
28
|
+
Dict,
|
|
29
|
+
Optional,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
from datacustomcode.named_credential.direct.credentials import CredentialError
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from datacustomcode.token_provider import TokenProvider
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
NAMED_CREDENTIAL_PATH = (
|
|
42
|
+
"services/data/v63.0/named-credentials/named-credential-setup/{name}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _callout_url_from_connect_api(
|
|
47
|
+
developer_name: str, token_provider: TokenProvider
|
|
48
|
+
) -> Optional[str]:
|
|
49
|
+
"""Fetch ``calloutUrl`` for a Named Credential, or None on any failure."""
|
|
50
|
+
try:
|
|
51
|
+
token = token_provider.get_token()
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
# No usable token (e.g. not logged in) — expected; fall back quietly.
|
|
54
|
+
logger.debug("Could not obtain a token for %s: %s", developer_name, exc)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
path = NAMED_CREDENTIAL_PATH.format(name=developer_name)
|
|
59
|
+
url = f"{token.instance_url.rstrip('/')}/{path}"
|
|
60
|
+
response = requests.get(
|
|
61
|
+
url,
|
|
62
|
+
headers={"Authorization": f"Bearer {token.access_token}"},
|
|
63
|
+
timeout=30,
|
|
64
|
+
)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
callout_url = response.json().get("calloutUrl")
|
|
67
|
+
return callout_url or None
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
# A token was obtained but the Connect API call failed — likely a
|
|
70
|
+
# misconfiguration (named credential not onboarded, missing
|
|
71
|
+
# permission, wrong org). Surface it before falling back to target_url.
|
|
72
|
+
logger.warning(
|
|
73
|
+
"Connect API URL resolution failed for %s: %s. "
|
|
74
|
+
"Falling back to 'target_url' from external_callout_config.json if set.",
|
|
75
|
+
developer_name,
|
|
76
|
+
exc,
|
|
77
|
+
)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_base_url(
|
|
82
|
+
callout_key: str,
|
|
83
|
+
cred_config: Dict[str, Any],
|
|
84
|
+
token_provider: Optional[TokenProvider],
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Resolve the base URL for a callout key.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
callout_key: e.g. ``callout:Nominatim_Geocoding``.
|
|
90
|
+
cred_config: The callout's ``external_callout_config.json`` entry.
|
|
91
|
+
token_provider: Provides a token/instance URL for the Connect API; when
|
|
92
|
+
None, only ``target_url`` is used.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
CredentialError: if no URL can be resolved.
|
|
96
|
+
"""
|
|
97
|
+
developer_name = callout_key.split(":", 1)[1] if ":" in callout_key else callout_key
|
|
98
|
+
|
|
99
|
+
base_url: Optional[str] = None
|
|
100
|
+
if token_provider is not None:
|
|
101
|
+
base_url = _callout_url_from_connect_api(developer_name, token_provider)
|
|
102
|
+
|
|
103
|
+
if not base_url:
|
|
104
|
+
base_url = cred_config.get("target_url") or None
|
|
105
|
+
|
|
106
|
+
if not base_url:
|
|
107
|
+
raise CredentialError(
|
|
108
|
+
f"Could not resolve a URL for '{callout_key}'. Ensure the Named "
|
|
109
|
+
f"Credential exists (sf login) or set 'target_url' in "
|
|
110
|
+
f"external_callout_config.json."
|
|
111
|
+
)
|
|
112
|
+
return base_url.rstrip("/")
|
|
@@ -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
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import (
|
|
19
|
+
TYPE_CHECKING,
|
|
20
|
+
Any,
|
|
21
|
+
Optional,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from datacustomcode.mixin import UserExtendableNamedConfigMixin
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pyspark.sql import Column
|
|
28
|
+
|
|
29
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
30
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SparkNamedCredential(ABC, UserExtendableNamedConfigMixin):
|
|
34
|
+
"""Named Credential external callout for script (Spark) code.
|
|
35
|
+
|
|
36
|
+
The callout is a one-shot request that runs on the driver. The endpoint and
|
|
37
|
+
its authentication are resolved from the Named Credential referenced by
|
|
38
|
+
``request.url``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
CONFIG_NAME: str
|
|
42
|
+
|
|
43
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def request(
|
|
48
|
+
self,
|
|
49
|
+
request: HTTPRequest,
|
|
50
|
+
body: Optional[str] = None,
|
|
51
|
+
) -> HTTPResponse:
|
|
52
|
+
"""Make an external callout through a Named Credential.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
request: The callout request
|
|
56
|
+
body: Optional request body. Set the
|
|
57
|
+
``Content-Type`` header yourself; the SDK does not assume one.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The external service's response.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def request_col(
|
|
66
|
+
self,
|
|
67
|
+
request: HTTPRequest,
|
|
68
|
+
body: Optional["Column"] = None,
|
|
69
|
+
) -> "Column":
|
|
70
|
+
"""Build a Spark ``Column`` that makes one external callout per row.
|
|
71
|
+
|
|
72
|
+
The endpoint, method, and headers are fixed for the call (taken from
|
|
73
|
+
``request``); only ``body`` varies per row. Use this instead of
|
|
74
|
+
:meth:`request` when the callout runs across a DataFrame so each row is
|
|
75
|
+
dispatched independently rather than one-shot on the driver.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
request: The callout template
|
|
79
|
+
body: Optional per-row ``Column`` holding the request body as a
|
|
80
|
+
string, sent verbatim (or null for no body).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A ``Column`` yielding a struct
|
|
84
|
+
``{status, response, error_code, error_message}``. ``response`` is
|
|
85
|
+
itself a struct ``{status_code, body, headers}`` carrying the callout
|
|
86
|
+
response. Select a field with ``[...]``, e.g.
|
|
87
|
+
``request_col(...)["response"]["status_code"]``. Returning a struct
|
|
88
|
+
means a single failing row does not abort the Spark job — a transport
|
|
89
|
+
failure sets ``status`` to ``ERROR`` with ``error_message``, while a
|
|
90
|
+
non-2xx HTTP response stays ``SUCCESS`` with its code in
|
|
91
|
+
``response.status_code``.
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
@@ -0,0 +1,154 @@
|
|
|
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 typing import (
|
|
18
|
+
TYPE_CHECKING,
|
|
19
|
+
Any,
|
|
20
|
+
Dict,
|
|
21
|
+
Optional,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pyspark.sql import Column
|
|
28
|
+
|
|
29
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
30
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
31
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_STATUS_SUCCESS = "SUCCESS"
|
|
35
|
+
_STATUS_ERROR = "ERROR"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_underlying_named_credential() -> "NamedCredential":
|
|
39
|
+
"""Build the callout object from the configured ``named_credential_config``.
|
|
40
|
+
|
|
41
|
+
Raises ``RuntimeError`` if no ``named_credential_config`` section is set.
|
|
42
|
+
"""
|
|
43
|
+
from datacustomcode.named_credential_config import named_credential_config
|
|
44
|
+
|
|
45
|
+
cfg = named_credential_config.named_credential_config
|
|
46
|
+
if cfg is None:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"named_credential_config is not configured. Add a "
|
|
49
|
+
"'named_credential_config' section to config.yaml."
|
|
50
|
+
)
|
|
51
|
+
return cfg.to_object()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DefaultSparkNamedCredential(SparkNamedCredential):
|
|
55
|
+
"""
|
|
56
|
+
Callout for Spark, delegating to the shared implementation.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
CONFIG_NAME = "DefaultSparkNamedCredential"
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
named_credential: Optional["NamedCredential"] = None,
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> None:
|
|
66
|
+
super().__init__(**kwargs)
|
|
67
|
+
if named_credential is None:
|
|
68
|
+
named_credential = _build_underlying_named_credential()
|
|
69
|
+
self._named_credential: "NamedCredential" = named_credential
|
|
70
|
+
|
|
71
|
+
def request(
|
|
72
|
+
self,
|
|
73
|
+
request: "HTTPRequest",
|
|
74
|
+
body: Optional[str] = None,
|
|
75
|
+
) -> "HTTPResponse":
|
|
76
|
+
return self._named_credential.request(request, body)
|
|
77
|
+
|
|
78
|
+
def request_col(
|
|
79
|
+
self,
|
|
80
|
+
request: "HTTPRequest",
|
|
81
|
+
body: Optional["Column"] = None,
|
|
82
|
+
) -> "Column":
|
|
83
|
+
"""Per-row callout via a client-side Spark UDF.
|
|
84
|
+
|
|
85
|
+
Returns a struct ``{status, response, error_code, error_message}`` where
|
|
86
|
+
``response`` is itself a struct
|
|
87
|
+
``{status_code, body, headers}`` carrying the callout's HTTP response, so
|
|
88
|
+
a script that selects ``request_col(...)["response"]["status_code"]``
|
|
89
|
+
behaves the same during development and in the Data Cloud runtime. Per-row
|
|
90
|
+
failures populate the error fields instead of aborting the Spark job.
|
|
91
|
+
"""
|
|
92
|
+
from pyspark.sql.functions import lit, udf
|
|
93
|
+
from pyspark.sql.types import (
|
|
94
|
+
IntegerType,
|
|
95
|
+
MapType,
|
|
96
|
+
StringType,
|
|
97
|
+
StructField,
|
|
98
|
+
StructType,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
http_response_schema = StructType(
|
|
102
|
+
[
|
|
103
|
+
StructField("status_code", IntegerType(), True),
|
|
104
|
+
StructField("body", StringType(), True),
|
|
105
|
+
StructField("headers", MapType(StringType(), StringType()), True),
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
result_schema = StructType(
|
|
109
|
+
[
|
|
110
|
+
StructField("status", StringType(), True),
|
|
111
|
+
StructField("response", http_response_schema, True),
|
|
112
|
+
StructField("error_code", StringType(), True),
|
|
113
|
+
StructField("error_message", StringType(), True),
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _callout(body_str: Optional[str]) -> Dict[str, Any]:
|
|
118
|
+
return _invoke_callout_as_struct(self._named_credential, request, body_str)
|
|
119
|
+
|
|
120
|
+
body_col = body if body is not None else lit(None).cast(StringType())
|
|
121
|
+
return udf(_callout, result_schema)(body_col)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _invoke_callout_as_struct(
|
|
125
|
+
named_credential: "NamedCredential",
|
|
126
|
+
request: "HTTPRequest",
|
|
127
|
+
body_str: Optional[str],
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""Run one callout and shape it into the shared result struct.
|
|
130
|
+
|
|
131
|
+
``response`` is a nested struct ``{status_code, body, headers}`` where
|
|
132
|
+
``body`` is the external response verbatim. Transport errors become per-row
|
|
133
|
+
ERROR structs rather than aborting the job.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
response = named_credential.request(request, body_str)
|
|
137
|
+
except Exception as exc: # surface any transport error per row
|
|
138
|
+
return {
|
|
139
|
+
"status": _STATUS_ERROR,
|
|
140
|
+
"response": None,
|
|
141
|
+
"error_code": None,
|
|
142
|
+
"error_message": str(exc),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"status": _STATUS_SUCCESS,
|
|
147
|
+
"response": {
|
|
148
|
+
"status_code": response.status_code,
|
|
149
|
+
"body": response.body,
|
|
150
|
+
"headers": response.headers,
|
|
151
|
+
},
|
|
152
|
+
"error_code": None,
|
|
153
|
+
"error_message": None,
|
|
154
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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.
|
|
@@ -0,0 +1,29 @@
|
|
|
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 enum import Enum
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTTPMethod(str, Enum):
|
|
20
|
+
"""Callout methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS); usable on
|
|
21
|
+
Python 3.10 where ``http.HTTPMethod`` is unavailable."""
|
|
22
|
+
|
|
23
|
+
GET = "GET"
|
|
24
|
+
POST = "POST"
|
|
25
|
+
PUT = "PUT"
|
|
26
|
+
DELETE = "DELETE"
|
|
27
|
+
PATCH = "PATCH"
|
|
28
|
+
HEAD = "HEAD"
|
|
29
|
+
OPTIONS = "OPTIONS"
|
|
@@ -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
|
+
from enum import Enum
|
|
17
|
+
from typing import Dict
|
|
18
|
+
|
|
19
|
+
from pydantic import (
|
|
20
|
+
BaseModel,
|
|
21
|
+
ConfigDict,
|
|
22
|
+
Field,
|
|
23
|
+
field_validator,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from datacustomcode.named_credential.types.http_method import HTTPMethod
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HTTPRequest(BaseModel):
|
|
30
|
+
"""External callout request. The endpoint and its auth are resolved
|
|
31
|
+
server-side from the Named Credential referenced by ``url``, which uses
|
|
32
|
+
``callout:<NamedCredential>/<path>`` syntax."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
35
|
+
|
|
36
|
+
url: str = Field(
|
|
37
|
+
...,
|
|
38
|
+
min_length=1,
|
|
39
|
+
description="Symbolic Named Credential reference, "
|
|
40
|
+
"e.g. 'callout:<NamedCredential>/<path>'",
|
|
41
|
+
)
|
|
42
|
+
method: str = Field(
|
|
43
|
+
default="GET",
|
|
44
|
+
description="HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)",
|
|
45
|
+
)
|
|
46
|
+
headers: Dict[str, str] = Field(default_factory=dict, description="Request headers")
|
|
47
|
+
|
|
48
|
+
@field_validator("method", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _normalize_method(cls, value: object) -> str:
|
|
51
|
+
# Accept str, this module's HTTPMethod, or http.HTTPMethod (3.11+).
|
|
52
|
+
if isinstance(value, Enum):
|
|
53
|
+
method = str(value.value)
|
|
54
|
+
else:
|
|
55
|
+
method = str(value)
|
|
56
|
+
method = method.upper()
|
|
57
|
+
if method not in {m.value for m in HTTPMethod}:
|
|
58
|
+
supported = ", ".join(m.value for m in HTTPMethod)
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Unsupported HTTP method '{method}'. "
|
|
61
|
+
f"Named Credential callouts support {supported}."
|
|
62
|
+
)
|
|
63
|
+
return method
|
|
@@ -0,0 +1,55 @@
|
|
|
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 Dict, Union
|
|
17
|
+
|
|
18
|
+
from datacustomcode.named_credential.types.http_method import HTTPMethod
|
|
19
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HTTPRequestBuilder:
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._url = ""
|
|
25
|
+
self._method: Union[str, HTTPMethod] = HTTPMethod.GET
|
|
26
|
+
self._headers: Dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
def set_url(self, url: str) -> "HTTPRequestBuilder":
|
|
29
|
+
"""Set the symbolic Named Credential reference.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
url: e.g. 'callout:<NamedCredential>/<path>'
|
|
33
|
+
"""
|
|
34
|
+
self._url = url
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def set_method(self, method: Union[str, HTTPMethod]) -> "HTTPRequestBuilder":
|
|
38
|
+
"""Set the HTTP method.
|
|
39
|
+
|
|
40
|
+
Accepts this module's ``HTTPMethod``, ``http.HTTPMethod`` (Python 3.11+),
|
|
41
|
+
or a plain string such as ``"GET"``.
|
|
42
|
+
"""
|
|
43
|
+
self._method = method
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def set_headers(self, headers: Dict[str, str]) -> "HTTPRequestBuilder":
|
|
47
|
+
self._headers = headers
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def build(self) -> HTTPRequest:
|
|
51
|
+
return HTTPRequest(
|
|
52
|
+
url=self._url,
|
|
53
|
+
method=self._method, # type: ignore[arg-type]
|
|
54
|
+
headers=self._headers,
|
|
55
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
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 Dict
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPResponse(BaseModel):
|
|
22
|
+
"""Response from a Named Credential external callout."""
|
|
23
|
+
|
|
24
|
+
status_code: int = Field(..., description="HTTP status code", ge=0)
|
|
25
|
+
headers: Dict[str, str] = Field(
|
|
26
|
+
default_factory=dict, description="Response headers"
|
|
27
|
+
)
|
|
28
|
+
body: str = Field(
|
|
29
|
+
default="",
|
|
30
|
+
description="Raw response body, verbatim (any format). The SDK does not "
|
|
31
|
+
"parse it; the caller decodes as needed (e.g. json.loads). Empty string "
|
|
32
|
+
"if the response had no body.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_success(self) -> bool:
|
|
37
|
+
"""Check if the request succeeded (2xx)."""
|
|
38
|
+
return 200 <= self.status_code < 300
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_error(self) -> bool:
|
|
42
|
+
"""Check if the request failed."""
|
|
43
|
+
return not self.is_success
|