salesforce-data-customcode 6.1.0.dev5__py3-none-any.whl → 6.1.0.dev7__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.
Files changed (36) hide show
  1. datacustomcode/client.py +99 -0
  2. datacustomcode/config.yaml +6 -0
  3. datacustomcode/deploy.py +7 -2
  4. datacustomcode/function/runtime.py +16 -0
  5. datacustomcode/named_credential/__init__.py +28 -0
  6. datacustomcode/named_credential/base.py +54 -0
  7. datacustomcode/named_credential/default.py +93 -0
  8. datacustomcode/named_credential/direct/__init__.py +19 -0
  9. datacustomcode/named_credential/direct/auth.py +191 -0
  10. datacustomcode/named_credential/direct/credentials.py +122 -0
  11. datacustomcode/named_credential/direct/transport.py +110 -0
  12. datacustomcode/named_credential/direct/url_resolver.py +112 -0
  13. datacustomcode/named_credential/errors.py +36 -0
  14. datacustomcode/named_credential/spark_base.py +93 -0
  15. datacustomcode/named_credential/spark_default.py +154 -0
  16. datacustomcode/named_credential/types/__init__.py +14 -0
  17. datacustomcode/named_credential/types/http_method.py +29 -0
  18. datacustomcode/named_credential/types/http_request.py +63 -0
  19. datacustomcode/named_credential/types/http_request_builder.py +88 -0
  20. datacustomcode/named_credential/types/http_response.py +43 -0
  21. datacustomcode/named_credential/types/http_response_builder.py +24 -0
  22. datacustomcode/named_credential_config.py +105 -0
  23. datacustomcode/run.py +7 -0
  24. datacustomcode/templates/function/example/chunking_with_external_callout/README.md +120 -0
  25. datacustomcode/templates/function/example/chunking_with_external_callout/config.json +3 -0
  26. datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py +162 -0
  27. datacustomcode/templates/function/example/chunking_with_external_callout/external_callout_config.json +11 -0
  28. datacustomcode/templates/function/example/chunking_with_external_callout/tests/test.json +16 -0
  29. datacustomcode/templates/script/examples/external_callout/README.md +140 -0
  30. datacustomcode/templates/script/examples/external_callout/entrypoint.py +144 -0
  31. datacustomcode/templates/script/examples/external_callout/external_callout_config.json +11 -0
  32. {salesforce_data_customcode-6.1.0.dev5.dist-info → salesforce_data_customcode-6.1.0.dev7.dist-info}/METADATA +1 -1
  33. {salesforce_data_customcode-6.1.0.dev5.dist-info → salesforce_data_customcode-6.1.0.dev7.dist-info}/RECORD +36 -10
  34. {salesforce_data_customcode-6.1.0.dev5.dist-info → salesforce_data_customcode-6.1.0.dev7.dist-info}/WHEEL +0 -0
  35. {salesforce_data_customcode-6.1.0.dev5.dist-info → salesforce_data_customcode-6.1.0.dev7.dist-info}/entry_points.txt +0 -0
  36. {salesforce_data_customcode-6.1.0.dev5.dist-info → salesforce_data_customcode-6.1.0.dev7.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,122 @@
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
+ AWS_SIG_V4 = "AwsSv4"
45
+ BASIC = "Basic"
46
+ CUSTOM = "Custom"
47
+ JWT = "Jwt"
48
+ OAUTH = "OAuth"
49
+
50
+
51
+ class CredentialError(RuntimeError):
52
+ """Raised when credentials cannot be found or are invalid."""
53
+
54
+
55
+ def _discover_credential_file() -> Optional[Path]:
56
+ """Find the config file via env override, then by walking up from cwd."""
57
+ override = os.environ.get(CREDENTIAL_FILE_ENV_VAR)
58
+ if override:
59
+ return Path(override)
60
+
61
+ for directory in (Path.cwd(), *Path.cwd().parents):
62
+ candidate = directory / EXTERNAL_CALLOUT_CREDENTIAL
63
+ if candidate.is_file():
64
+ return candidate
65
+ return None
66
+
67
+
68
+ class CredentialStore:
69
+ """Reads ``external_callout_config.json`` and returns per-callout config."""
70
+
71
+ def __init__(self, credential_file: Optional[str] = None) -> None:
72
+ self._explicit_path = Path(credential_file) if credential_file else None
73
+ self._credentials: Optional[Dict[str, Dict[str, Any]]] = None
74
+
75
+ def _load(self) -> Dict[str, Dict[str, Any]]:
76
+ if self._credentials is not None:
77
+ return self._credentials
78
+
79
+ path = self._explicit_path or _discover_credential_file()
80
+ if path is None or not path.is_file():
81
+ raise CredentialError(
82
+ f"Could not find '{EXTERNAL_CALLOUT_CREDENTIAL}'. Place it in the "
83
+ f"parent of your payload folder, or set "
84
+ f"${CREDENTIAL_FILE_ENV_VAR} to its path."
85
+ )
86
+ try:
87
+ with open(path, "r") as f:
88
+ data = json.load(f)
89
+ except (OSError, json.JSONDecodeError) as exc:
90
+ raise CredentialError(f"Failed to read '{path}': {exc}") from exc
91
+
92
+ # Per-callout entries live under the ``credentials`` section, leaving
93
+ # room for other config sections alongside them in the future.
94
+ credentials = data.get("credentials") if isinstance(data, dict) else None
95
+ if not isinstance(credentials, dict):
96
+ raise CredentialError(
97
+ f"'{path}' must be a JSON object with a 'credentials' section "
98
+ f"keyed by callout reference."
99
+ )
100
+ self._credentials = credentials
101
+ return credentials
102
+
103
+ def get(self, callout_key: str) -> Dict[str, Any]:
104
+ """Return the config for a callout key (e.g. ``callout:AWS_S3_Service``).
105
+
106
+ Raises:
107
+ CredentialError: if the key is missing or has no ``auth_type``.
108
+ """
109
+ credentials = self._load()
110
+ config = credentials.get(callout_key)
111
+ if config is None:
112
+ raise CredentialError(
113
+ f"No credential configuration found for '{callout_key}'. "
114
+ f"Add it to the 'credentials' section of "
115
+ f"'{EXTERNAL_CALLOUT_CREDENTIAL}'."
116
+ )
117
+ if not isinstance(config, dict) or not config.get("auth_type"):
118
+ raise CredentialError(
119
+ f"Credential for '{callout_key}' is missing the mandatory "
120
+ f"'auth_type' field."
121
+ )
122
+ return config
@@ -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 CredentialStore
35
+ from datacustomcode.named_credential.direct.url_resolver import resolve_base_url
36
+ from datacustomcode.named_credential.errors import NamedCredentialCallError
37
+ from datacustomcode.token_provider import (
38
+ CredentialsTokenProvider,
39
+ SFCLITokenProvider,
40
+ TokenProvider,
41
+ )
42
+
43
+
44
+ class DirectCalloutTransport:
45
+ def __init__(
46
+ self,
47
+ credentials_profile: str = "default",
48
+ sf_cli_org: Optional[str] = None,
49
+ credential_file: Optional[str] = None,
50
+ ) -> None:
51
+ self._store = CredentialStore(credential_file)
52
+ self._token_provider = self._build_token_provider(
53
+ credentials_profile, sf_cli_org
54
+ )
55
+ # Resolved base URL per callout key. Stable for the transport's life, so
56
+ # cache it to avoid a token fetch + Connect API call on every row
57
+ self._base_url_cache: Dict[str, str] = {}
58
+
59
+ @staticmethod
60
+ def _build_token_provider(
61
+ credentials_profile: str, sf_cli_org: Optional[str]
62
+ ) -> TokenProvider:
63
+ if sf_cli_org:
64
+ return SFCLITokenProvider(sf_cli_org)
65
+ return CredentialsTokenProvider(credentials_profile)
66
+
67
+ def callout(self, callout_request: Dict[str, Any]) -> Dict[str, Any]:
68
+ raw_url = callout_request["path"]
69
+ if not raw_url.startswith("callout:"):
70
+ raise NamedCredentialCallError(
71
+ f"Callout URL must start with 'callout:', got '{raw_url}'."
72
+ )
73
+
74
+ # Split the named credential reference at the first '/' or '?'; the remainder
75
+ # path or query string is appended to the resolved base URL verbatim.
76
+ sep_idx = min(
77
+ (i for i in (raw_url.find("/"), raw_url.find("?")) if i != -1),
78
+ default=len(raw_url),
79
+ )
80
+ callout_key = raw_url[:sep_idx]
81
+ path_suffix = raw_url[sep_idx:]
82
+
83
+ if callout_key == "callout:":
84
+ raise NamedCredentialCallError(
85
+ f"Named Credential name is empty in URL '{raw_url}'."
86
+ )
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,36 @@
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
+ """Exceptions raised by Named Credential implementations."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+
22
+ class NamedCredentialCallError(RuntimeError):
23
+ """Raised when a Named Credential external callout fails."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ status: Optional[object] = None,
30
+ error_code: Optional[str] = None,
31
+ error_message: Optional[str] = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.status = status
35
+ self.error_code = error_code
36
+ self.error_message = error_message
@@ -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"