airbyte-agent-facebook-marketing 0.1.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.
Files changed (57) hide show
  1. airbyte_agent_facebook_marketing/__init__.py +221 -0
  2. airbyte_agent_facebook_marketing/_vendored/__init__.py +1 -0
  3. airbyte_agent_facebook_marketing/_vendored/connector_sdk/__init__.py +82 -0
  4. airbyte_agent_facebook_marketing/_vendored/connector_sdk/auth_strategies.py +1171 -0
  5. airbyte_agent_facebook_marketing/_vendored/connector_sdk/auth_template.py +135 -0
  6. airbyte_agent_facebook_marketing/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  7. airbyte_agent_facebook_marketing/_vendored/connector_sdk/cloud_utils/client.py +213 -0
  8. airbyte_agent_facebook_marketing/_vendored/connector_sdk/connector_model_loader.py +1120 -0
  9. airbyte_agent_facebook_marketing/_vendored/connector_sdk/constants.py +78 -0
  10. airbyte_agent_facebook_marketing/_vendored/connector_sdk/exceptions.py +23 -0
  11. airbyte_agent_facebook_marketing/_vendored/connector_sdk/executor/__init__.py +31 -0
  12. airbyte_agent_facebook_marketing/_vendored/connector_sdk/executor/hosted_executor.py +201 -0
  13. airbyte_agent_facebook_marketing/_vendored/connector_sdk/executor/local_executor.py +1854 -0
  14. airbyte_agent_facebook_marketing/_vendored/connector_sdk/executor/models.py +202 -0
  15. airbyte_agent_facebook_marketing/_vendored/connector_sdk/extensions.py +693 -0
  16. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/__init__.py +37 -0
  17. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  18. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  19. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/config.py +98 -0
  20. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/exceptions.py +119 -0
  21. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/protocols.py +114 -0
  22. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http/response.py +104 -0
  23. airbyte_agent_facebook_marketing/_vendored/connector_sdk/http_client.py +693 -0
  24. airbyte_agent_facebook_marketing/_vendored/connector_sdk/introspection.py +481 -0
  25. airbyte_agent_facebook_marketing/_vendored/connector_sdk/logging/__init__.py +11 -0
  26. airbyte_agent_facebook_marketing/_vendored/connector_sdk/logging/logger.py +273 -0
  27. airbyte_agent_facebook_marketing/_vendored/connector_sdk/logging/types.py +93 -0
  28. airbyte_agent_facebook_marketing/_vendored/connector_sdk/observability/__init__.py +11 -0
  29. airbyte_agent_facebook_marketing/_vendored/connector_sdk/observability/config.py +179 -0
  30. airbyte_agent_facebook_marketing/_vendored/connector_sdk/observability/models.py +19 -0
  31. airbyte_agent_facebook_marketing/_vendored/connector_sdk/observability/redactor.py +81 -0
  32. airbyte_agent_facebook_marketing/_vendored/connector_sdk/observability/session.py +103 -0
  33. airbyte_agent_facebook_marketing/_vendored/connector_sdk/performance/__init__.py +6 -0
  34. airbyte_agent_facebook_marketing/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  35. airbyte_agent_facebook_marketing/_vendored/connector_sdk/performance/metrics.py +93 -0
  36. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/__init__.py +75 -0
  37. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/base.py +201 -0
  38. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/components.py +244 -0
  39. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/connector.py +120 -0
  40. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/extensions.py +301 -0
  41. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/operations.py +156 -0
  42. airbyte_agent_facebook_marketing/_vendored/connector_sdk/schema/security.py +236 -0
  43. airbyte_agent_facebook_marketing/_vendored/connector_sdk/secrets.py +182 -0
  44. airbyte_agent_facebook_marketing/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  45. airbyte_agent_facebook_marketing/_vendored/connector_sdk/telemetry/config.py +32 -0
  46. airbyte_agent_facebook_marketing/_vendored/connector_sdk/telemetry/events.py +59 -0
  47. airbyte_agent_facebook_marketing/_vendored/connector_sdk/telemetry/tracker.py +155 -0
  48. airbyte_agent_facebook_marketing/_vendored/connector_sdk/types.py +270 -0
  49. airbyte_agent_facebook_marketing/_vendored/connector_sdk/utils.py +60 -0
  50. airbyte_agent_facebook_marketing/_vendored/connector_sdk/validation.py +848 -0
  51. airbyte_agent_facebook_marketing/connector.py +1553 -0
  52. airbyte_agent_facebook_marketing/connector_model.py +3120 -0
  53. airbyte_agent_facebook_marketing/models.py +814 -0
  54. airbyte_agent_facebook_marketing/types.py +1957 -0
  55. airbyte_agent_facebook_marketing-0.1.0.dist-info/METADATA +148 -0
  56. airbyte_agent_facebook_marketing-0.1.0.dist-info/RECORD +57 -0
  57. airbyte_agent_facebook_marketing-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,1854 @@
1
+ """Local executor for direct HTTP execution of connector operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any, Protocol
13
+ from urllib.parse import quote
14
+
15
+ from jinja2 import Environment, StrictUndefined
16
+ from jsonpath_ng import parse as parse_jsonpath
17
+ from opentelemetry import trace
18
+
19
+ from ..auth_template import apply_auth_mapping
20
+ from ..connector_model_loader import load_connector_model
21
+ from ..constants import (
22
+ DEFAULT_MAX_CONNECTIONS,
23
+ DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
24
+ )
25
+ from ..http_client import HTTPClient, TokenRefreshCallback
26
+ from ..logging import NullLogger, RequestLogger
27
+ from ..observability import ObservabilitySession
28
+ from ..schema.extensions import RetryConfig
29
+ from ..secrets import SecretStr
30
+ from ..telemetry import SegmentTracker
31
+ from ..types import (
32
+ Action,
33
+ AuthConfig,
34
+ AuthOption,
35
+ ConnectorModel,
36
+ EndpointDefinition,
37
+ EntityDefinition,
38
+ )
39
+
40
+ from .models import (
41
+ ActionNotSupportedError,
42
+ EntityNotFoundError,
43
+ ExecutionConfig,
44
+ ExecutionResult,
45
+ ExecutorError,
46
+ InvalidParameterError,
47
+ MissingParameterError,
48
+ StandardExecuteResult,
49
+ )
50
+
51
+
52
+ class _OperationContext:
53
+ """Shared context for operation handlers."""
54
+
55
+ def __init__(self, executor: LocalExecutor):
56
+ self.executor = executor
57
+ self.http_client = executor.http_client
58
+ self.tracker = executor.tracker
59
+ self.session = executor.session
60
+ self.logger = executor.logger
61
+ self.entity_index = executor._entity_index
62
+ self.operation_index = executor._operation_index
63
+ # Bind helper methods
64
+ self.build_path = executor._build_path
65
+ self.extract_query_params = executor._extract_query_params
66
+ self.extract_body = executor._extract_body
67
+ self.extract_header_params = executor._extract_header_params
68
+ self.build_request_body = executor._build_request_body
69
+ self.determine_request_format = executor._determine_request_format
70
+ self.validate_required_body_fields = executor._validate_required_body_fields
71
+ self.extract_records = executor._extract_records
72
+
73
+ @property
74
+ def standard_handler(self) -> _StandardOperationHandler | None:
75
+ """Return the standard operation handler, or None if not registered."""
76
+ for h in self.executor._operation_handlers:
77
+ if isinstance(h, _StandardOperationHandler):
78
+ return h
79
+ return None
80
+
81
+
82
+ class _OperationHandler(Protocol):
83
+ """Protocol for operation handlers."""
84
+
85
+ def can_handle(self, action: Action) -> bool:
86
+ """Check if this handler can handle the given action."""
87
+ ...
88
+
89
+ async def execute_operation(
90
+ self,
91
+ entity: str,
92
+ action: Action,
93
+ params: dict[str, Any],
94
+ ) -> StandardExecuteResult | AsyncIterator[bytes]:
95
+ """Execute the operation and return result.
96
+
97
+ Returns:
98
+ StandardExecuteResult for standard operations (GET, LIST, CREATE, etc.)
99
+ AsyncIterator[bytes] for download operations
100
+ """
101
+ ...
102
+
103
+
104
+ class LocalExecutor:
105
+ """Async executor for Entity×Action operations with direct HTTP execution.
106
+
107
+ This is the "local mode" executor that makes direct HTTP calls to external APIs.
108
+ It performs local entity/action lookups, validation, and request building.
109
+
110
+ Implements ExecutorProtocol.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ config_path: str | None = None,
116
+ model: ConnectorModel | None = None,
117
+ secrets: dict[str, SecretStr] | None = None,
118
+ auth_config: dict[str, SecretStr] | None = None,
119
+ auth_scheme: str | None = None,
120
+ enable_logging: bool = False,
121
+ log_file: str | None = None,
122
+ execution_context: str | None = None,
123
+ max_connections: int = DEFAULT_MAX_CONNECTIONS,
124
+ max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
125
+ max_logs: int | None = 10000,
126
+ config_values: dict[str, str] | None = None,
127
+ on_token_refresh: TokenRefreshCallback = None,
128
+ retry_config: RetryConfig | None = None,
129
+ ):
130
+ """Initialize async executor.
131
+
132
+ Args:
133
+ config_path: Path to connector.yaml.
134
+ If neither config_path nor model is provided, an error will be raised.
135
+ model: ConnectorModel object to execute.
136
+ secrets: (Legacy) Auth parameters that bypass x-airbyte-auth-config mapping.
137
+ Directly passed to auth strategies (e.g., {"username": "...", "password": "..."}).
138
+ Cannot be used together with auth_config.
139
+ auth_config: User-facing auth configuration following x-airbyte-auth-config spec.
140
+ Will be transformed via auth_mapping to produce auth parameters.
141
+ Cannot be used together with secrets.
142
+ auth_scheme: (Multi-auth only) Explicit security scheme name to use.
143
+ If None, SDK will auto-select based on provided credentials.
144
+ Example: auth_scheme="githubOAuth"
145
+ enable_logging: Enable request/response logging
146
+ log_file: Path to log file (if enable_logging=True)
147
+ execution_context: Execution context (mcp, direct, blessed, agent)
148
+ max_connections: Maximum number of concurrent connections
149
+ max_keepalive_connections: Maximum number of keepalive connections
150
+ max_logs: Maximum number of logs to keep in memory before rotation.
151
+ Set to None for unlimited (not recommended for production).
152
+ Defaults to 10000.
153
+ config_values: Optional dict of config values for server variable substitution
154
+ (e.g., {"subdomain": "acme"} for URLs like https://{subdomain}.api.example.com).
155
+ on_token_refresh: Optional callback function(new_tokens: dict) called when
156
+ OAuth2 tokens are refreshed. Use this to persist updated tokens.
157
+ Can be sync or async. Example: lambda tokens: save_to_db(tokens)
158
+ retry_config: Optional retry configuration override. If provided, overrides
159
+ the connector.yaml x-airbyte-retry-config. If None, uses connector.yaml
160
+ config or SDK defaults.
161
+ """
162
+ # Validate mutual exclusivity of secrets and auth_config
163
+ if secrets is not None and auth_config is not None:
164
+ raise ValueError(
165
+ "Cannot provide both 'secrets' and 'auth_config' parameters. "
166
+ "Use 'auth_config' for user-facing credentials (recommended), "
167
+ "or 'secrets' for direct auth parameters (legacy)."
168
+ )
169
+
170
+ # Validate mutual exclusivity of config_path and model
171
+ if config_path is not None and model is not None:
172
+ raise ValueError("Cannot provide both 'config_path' and 'model' parameters.")
173
+
174
+ if config_path is None and model is None:
175
+ raise ValueError("Must provide either 'config_path' or 'model' parameter.")
176
+
177
+ # Load model from path or use provided model
178
+ if config_path is not None:
179
+ self.model: ConnectorModel = load_connector_model(config_path)
180
+ else:
181
+ self.model: ConnectorModel = model
182
+
183
+ self.on_token_refresh = on_token_refresh
184
+ self.config_values = config_values or {}
185
+
186
+ # Handle auth selection for multi-auth or single-auth connectors
187
+ user_credentials = auth_config if auth_config is not None else secrets
188
+ selected_auth_config, self.secrets = self._initialize_auth(user_credentials, auth_scheme)
189
+
190
+ # Create shared observability session
191
+ self.session = ObservabilitySession(
192
+ connector_name=self.model.name,
193
+ connector_version=getattr(self.model, "version", None),
194
+ execution_context=(execution_context or os.getenv("AIRBYTE_EXECUTION_CONTEXT", "direct")),
195
+ )
196
+
197
+ # Initialize telemetry tracker
198
+ self.tracker = SegmentTracker(self.session)
199
+ self.tracker.track_connector_init(connector_version=getattr(self.model, "version", None))
200
+
201
+ # Initialize logger
202
+ if enable_logging:
203
+ self.logger = RequestLogger(
204
+ log_file=log_file,
205
+ connector_name=self.model.name,
206
+ max_logs=max_logs,
207
+ )
208
+ else:
209
+ self.logger = NullLogger()
210
+
211
+ # Initialize async HTTP client with connection pooling
212
+ self.http_client = HTTPClient(
213
+ base_url=self.model.base_url,
214
+ auth_config=selected_auth_config,
215
+ secrets=self.secrets,
216
+ config_values=self.config_values,
217
+ logger=self.logger,
218
+ max_connections=max_connections,
219
+ max_keepalive_connections=max_keepalive_connections,
220
+ on_token_refresh=on_token_refresh,
221
+ retry_config=retry_config or self.model.retry_config,
222
+ )
223
+
224
+ # Build O(1) lookup indexes
225
+ self._entity_index: dict[str, EntityDefinition] = {entity.name: entity for entity in self.model.entities}
226
+
227
+ # Build O(1) operation index: (entity, action) -> endpoint
228
+ self._operation_index: dict[tuple[str, Action], Any] = {}
229
+ for entity in self.model.entities:
230
+ for action in entity.actions:
231
+ endpoint = entity.endpoints.get(action)
232
+ if endpoint:
233
+ self._operation_index[(entity.name, action)] = endpoint
234
+
235
+ # Register operation handlers (order matters for can_handle priority)
236
+ op_context = _OperationContext(self)
237
+ self._operation_handlers: list[_OperationHandler] = [
238
+ _DownloadOperationHandler(op_context),
239
+ _StandardOperationHandler(op_context),
240
+ ]
241
+
242
+ def _apply_auth_config_mapping(self, user_secrets: dict[str, SecretStr]) -> dict[str, SecretStr]:
243
+ """Apply auth_mapping from x-airbyte-auth-config to transform user secrets.
244
+
245
+ This method takes user-provided secrets (e.g., {"api_token": "abc123"}) and
246
+ transforms them into the auth scheme format (e.g., {"username": "abc123", "password": "api_token"})
247
+ using the template mappings defined in x-airbyte-auth-config.
248
+
249
+ Args:
250
+ user_secrets: User-provided secrets from config
251
+
252
+ Returns:
253
+ Transformed secrets matching the auth scheme requirements
254
+ """
255
+ if not self.model.auth.user_config_spec:
256
+ # No x-airbyte-auth-config defined, use secrets as-is
257
+ return user_secrets
258
+
259
+ user_config_spec = self.model.auth.user_config_spec
260
+ auth_mapping = None
261
+ required_fields: list[str] | None = None
262
+
263
+ # Check for single option (direct auth_mapping)
264
+ if user_config_spec.auth_mapping:
265
+ auth_mapping = user_config_spec.auth_mapping
266
+ required_fields = user_config_spec.required
267
+ # Check for oneOf (multiple auth options)
268
+ elif user_config_spec.one_of:
269
+ # Find the matching option based on which required fields are present
270
+ for option in user_config_spec.one_of:
271
+ option_required = option.required or []
272
+ if all(field in user_secrets for field in option_required):
273
+ auth_mapping = option.auth_mapping
274
+ required_fields = option_required
275
+ break
276
+
277
+ if not auth_mapping:
278
+ # No matching auth_mapping found, use secrets as-is
279
+ return user_secrets
280
+
281
+ # If required fields are missing and user provided no credentials,
282
+ # return as-is (allows empty auth for testing or optional auth)
283
+ if required_fields and not user_secrets:
284
+ return user_secrets
285
+
286
+ # Convert SecretStr values to plain strings for template processing
287
+ user_config_values = {
288
+ key: (value.get_secret_value() if hasattr(value, "get_secret_value") else str(value)) for key, value in user_secrets.items()
289
+ }
290
+
291
+ # Apply the auth_mapping templates, passing required_fields so optional
292
+ # fields that are not provided can be skipped
293
+ mapped_values = apply_auth_mapping(auth_mapping, user_config_values, required_fields=required_fields)
294
+
295
+ # Convert back to SecretStr
296
+ mapped_secrets = {key: SecretStr(value) for key, value in mapped_values.items()}
297
+
298
+ return mapped_secrets
299
+
300
+ def _initialize_auth(
301
+ self,
302
+ user_credentials: dict[str, SecretStr] | None,
303
+ explicit_scheme: str | None,
304
+ ) -> tuple[AuthConfig, dict[str, SecretStr] | None]:
305
+ """Initialize authentication for single or multi-auth connectors.
306
+
307
+ Handles both legacy single-auth and new multi-auth connectors.
308
+ For multi-auth, the auth scheme can be explicitly provided or inferred
309
+ from the provided credentials by matching against each scheme's required fields.
310
+
311
+ Args:
312
+ user_credentials: User-provided credentials (auth_config or secrets)
313
+ explicit_scheme: Explicit scheme name for multi-auth (optional, will be
314
+ inferred from credentials if not provided)
315
+
316
+ Returns:
317
+ Tuple of (selected AuthConfig for HTTPClient, transformed secrets)
318
+
319
+ Raises:
320
+ ValueError: If multi-auth connector can't determine which scheme to use
321
+ """
322
+ # Multi-auth: explicit scheme selection or inference from credentials
323
+ if self.model.auth.is_multi_auth():
324
+ if not user_credentials:
325
+ available_schemes = [opt.scheme_name for opt in self.model.auth.options]
326
+ raise ValueError(f"Multi-auth connector requires credentials. Available schemes: {available_schemes}")
327
+
328
+ # If explicit scheme provided, use it directly
329
+ if explicit_scheme:
330
+ selected_option, transformed_secrets = self._select_auth_option(user_credentials, explicit_scheme)
331
+ else:
332
+ # Infer auth scheme from provided credentials
333
+ selected_option, transformed_secrets = self._infer_auth_scheme(user_credentials)
334
+
335
+ # Convert AuthOption to single-auth AuthConfig for HTTPClient
336
+ selected_auth_config = AuthConfig(
337
+ type=selected_option.type,
338
+ config=selected_option.config,
339
+ user_config_spec=None, # Not needed by HTTPClient
340
+ )
341
+
342
+ return (selected_auth_config, transformed_secrets)
343
+
344
+ # Single-auth: use existing logic
345
+ if user_credentials is not None:
346
+ # Apply mapping if this is auth_config (not legacy secrets)
347
+ transformed_secrets = self._apply_auth_config_mapping(user_credentials)
348
+ else:
349
+ transformed_secrets = None
350
+
351
+ return (self.model.auth, transformed_secrets)
352
+
353
+ def _infer_auth_scheme(
354
+ self,
355
+ user_credentials: dict[str, SecretStr],
356
+ ) -> tuple[AuthOption, dict[str, SecretStr]]:
357
+ """Infer authentication scheme from provided credentials.
358
+
359
+ Matches user credentials against each auth option's required fields
360
+ to determine which scheme to use.
361
+
362
+ Args:
363
+ user_credentials: User-provided credentials
364
+
365
+ Returns:
366
+ Tuple of (inferred AuthOption, transformed secrets)
367
+
368
+ Raises:
369
+ ValueError: If no scheme matches, or multiple schemes match
370
+ """
371
+ options = self.model.auth.options
372
+ if not options:
373
+ raise ValueError("No auth options available in multi-auth config")
374
+
375
+ # Get the credential keys provided by the user
376
+ provided_keys = set(user_credentials.keys())
377
+
378
+ # Find all options where all required fields are present
379
+ matching_options: list[AuthOption] = []
380
+ for option in options:
381
+ if option.user_config_spec and option.user_config_spec.required:
382
+ required_fields = set(option.user_config_spec.required)
383
+ if required_fields.issubset(provided_keys):
384
+ matching_options.append(option)
385
+ elif not option.user_config_spec or not option.user_config_spec.required:
386
+ # Option has no required fields - it matches any credentials
387
+ matching_options.append(option)
388
+
389
+ # Handle matching results
390
+ if len(matching_options) == 0:
391
+ # No matches - provide helpful error message
392
+ scheme_requirements = []
393
+ for opt in options:
394
+ required = opt.user_config_spec.required if opt.user_config_spec and opt.user_config_spec.required else []
395
+ scheme_requirements.append(f" - {opt.scheme_name}: requires {required}")
396
+ raise ValueError(
397
+ f"Could not infer auth scheme from provided credentials. "
398
+ f"Provided keys: {list(provided_keys)}. "
399
+ f"Available schemes and their required fields:\n" + "\n".join(scheme_requirements)
400
+ )
401
+
402
+ if len(matching_options) > 1:
403
+ # Multiple matches - need explicit scheme
404
+ matching_names = [opt.scheme_name for opt in matching_options]
405
+ raise ValueError(
406
+ f"Multiple auth schemes match the provided credentials: {matching_names}. Please specify 'auth_scheme' explicitly to disambiguate."
407
+ )
408
+
409
+ # Exactly one match - use it
410
+ selected_option = matching_options[0]
411
+ transformed_secrets = self._apply_auth_mapping_for_option(user_credentials, selected_option)
412
+ return (selected_option, transformed_secrets)
413
+
414
+ def _select_auth_option(
415
+ self,
416
+ user_credentials: dict[str, SecretStr],
417
+ scheme_name: str,
418
+ ) -> tuple[AuthOption, dict[str, SecretStr]]:
419
+ """Select authentication option by explicit scheme name.
420
+
421
+ Args:
422
+ user_credentials: User-provided credentials
423
+ scheme_name: Explicit scheme name (e.g., "githubOAuth")
424
+
425
+ Returns:
426
+ Tuple of (selected AuthOption, transformed secrets)
427
+
428
+ Raises:
429
+ ValueError: If scheme not found
430
+ """
431
+ options = self.model.auth.options
432
+ if not options:
433
+ raise ValueError("No auth options available in multi-auth config")
434
+
435
+ # Find matching scheme
436
+ for option in options:
437
+ if option.scheme_name == scheme_name:
438
+ transformed_secrets = self._apply_auth_mapping_for_option(user_credentials, option)
439
+ return (option, transformed_secrets)
440
+
441
+ # Scheme not found
442
+ available = [opt.scheme_name for opt in options]
443
+ raise ValueError(f"Auth scheme '{scheme_name}' not found. Available schemes: {available}")
444
+
445
+ def _apply_auth_mapping_for_option(
446
+ self,
447
+ user_credentials: dict[str, SecretStr],
448
+ option: AuthOption,
449
+ ) -> dict[str, SecretStr]:
450
+ """Apply auth mapping for a specific auth option.
451
+
452
+ Transforms user credentials using the option's auth_mapping templates.
453
+
454
+ Args:
455
+ user_credentials: User-provided credentials
456
+ option: AuthOption to apply
457
+
458
+ Returns:
459
+ Transformed secrets after applying auth_mapping
460
+
461
+ Raises:
462
+ ValueError: If required fields are missing or mapping fails
463
+ """
464
+ if not option.user_config_spec:
465
+ # No mapping defined, use credentials as-is
466
+ return user_credentials
467
+
468
+ # Extract auth_mapping and required fields
469
+ user_config_spec = option.user_config_spec
470
+ auth_mapping = user_config_spec.auth_mapping
471
+ required_fields = user_config_spec.required
472
+
473
+ if not auth_mapping:
474
+ raise ValueError(f"No auth_mapping found for scheme '{option.scheme_name}'")
475
+
476
+ # Convert SecretStr to plain strings for template processing
477
+ user_config_values = {
478
+ key: (value.get_secret_value() if hasattr(value, "get_secret_value") else str(value)) for key, value in user_credentials.items()
479
+ }
480
+
481
+ # Apply the auth_mapping templates
482
+ mapped_values = apply_auth_mapping(auth_mapping, user_config_values, required_fields=required_fields)
483
+
484
+ # Convert back to SecretStr
485
+ return {key: SecretStr(value) for key, value in mapped_values.items()}
486
+
487
+ async def execute(self, config: ExecutionConfig) -> ExecutionResult:
488
+ """Execute connector operation using handler pattern.
489
+
490
+ Args:
491
+ config: Execution configuration (entity, action, params)
492
+
493
+ Returns:
494
+ ExecutionResult with success/failure status and data
495
+
496
+ Example:
497
+ config = ExecutionConfig(
498
+ entity="customers",
499
+ action="list",
500
+ params={"limit": 10}
501
+ )
502
+ result = await executor.execute(config)
503
+ if result.success:
504
+ print(result.data)
505
+ """
506
+ try:
507
+ # Check for hosted-only actions before converting to Action enum
508
+ if config.action == "search":
509
+ raise NotImplementedError(
510
+ "search is only available in hosted execution mode. "
511
+ "Initialize the connector with external_user_id, airbyte_client_id, "
512
+ "and airbyte_client_secret to use this feature."
513
+ )
514
+
515
+ # Convert config to internal format
516
+ action = Action(config.action) if isinstance(config.action, str) else config.action
517
+ params = config.params or {}
518
+
519
+ # Dispatch to handler (handlers handle telemetry internally)
520
+ handler = next((h for h in self._operation_handlers if h.can_handle(action)), None)
521
+ if not handler:
522
+ raise ExecutorError(f"No handler registered for action '{action.value}'.")
523
+
524
+ # Execute handler
525
+ result = handler.execute_operation(config.entity, action, params)
526
+
527
+ # Check if it's an async generator (download) or awaitable (standard)
528
+ if inspect.isasyncgen(result):
529
+ # Download operation: return generator directly
530
+ return ExecutionResult(
531
+ success=True,
532
+ data=result,
533
+ error=None,
534
+ meta=None,
535
+ )
536
+ else:
537
+ # Standard operation: await and extract data and metadata
538
+ handler_result = await result
539
+ return ExecutionResult(
540
+ success=True,
541
+ data=handler_result.data,
542
+ error=None,
543
+ meta=handler_result.metadata,
544
+ )
545
+
546
+ except (
547
+ EntityNotFoundError,
548
+ ActionNotSupportedError,
549
+ MissingParameterError,
550
+ InvalidParameterError,
551
+ ) as e:
552
+ # These are "expected" execution errors - return them in ExecutionResult
553
+ return ExecutionResult(success=False, data={}, error=str(e))
554
+
555
+ async def check(self) -> ExecutionResult:
556
+ """Perform a health check by running a lightweight list operation.
557
+
558
+ Finds the operation marked with preferred_for_check=True, or falls back
559
+ to the first list operation. Executes it with limit=1 to verify
560
+ connectivity and credentials.
561
+
562
+ Returns:
563
+ ExecutionResult with data containing status, error, and checked operation details.
564
+ """
565
+ check_entity = None
566
+ check_endpoint = None
567
+
568
+ # Look for preferred check operation
569
+ for (ent_name, op_action), endpoint in self._operation_index.items():
570
+ if getattr(endpoint, "preferred_for_check", False):
571
+ check_entity = ent_name
572
+ check_endpoint = endpoint
573
+ break
574
+
575
+ # Fallback to first list operation
576
+ if check_endpoint is None:
577
+ for (ent_name, op_action), endpoint in self._operation_index.items():
578
+ if op_action == Action.LIST:
579
+ check_entity = ent_name
580
+ check_endpoint = endpoint
581
+ break
582
+
583
+ if check_endpoint is None or check_entity is None:
584
+ return ExecutionResult(
585
+ success=True,
586
+ data={
587
+ "status": "unhealthy",
588
+ "error": "No list operation available for health check",
589
+ },
590
+ )
591
+
592
+ # Find the standard handler to execute the list operation
593
+ standard_handler = next(
594
+ (h for h in self._operation_handlers if isinstance(h, _StandardOperationHandler)),
595
+ None,
596
+ )
597
+
598
+ if standard_handler is None:
599
+ return ExecutionResult(
600
+ success=True,
601
+ data={
602
+ "status": "unhealthy",
603
+ "error": "No standard handler available",
604
+ },
605
+ )
606
+
607
+ try:
608
+ await standard_handler.execute_operation(check_entity, Action.LIST, {"limit": 1})
609
+ return ExecutionResult(
610
+ success=True,
611
+ data={
612
+ "status": "healthy",
613
+ "checked_entity": check_entity,
614
+ "checked_action": "list",
615
+ },
616
+ )
617
+ except Exception as e:
618
+ return ExecutionResult(
619
+ success=True,
620
+ data={
621
+ "status": "unhealthy",
622
+ "error": str(e),
623
+ "checked_entity": check_entity,
624
+ "checked_action": "list",
625
+ },
626
+ )
627
+
628
+ async def _execute_operation(
629
+ self,
630
+ entity: str,
631
+ action: str | Action,
632
+ params: dict[str, Any] | None = None,
633
+ ) -> dict[str, Any]:
634
+ """Internal method: Execute an action on an entity asynchronously.
635
+
636
+ This method now delegates to the appropriate handler and extracts just the data.
637
+ External code should use execute(config) instead for full ExecutionResult with metadata.
638
+
639
+ Args:
640
+ entity: Entity name (e.g., "Customer")
641
+ action: Action to execute (e.g., "get" or Action.GET)
642
+ params: Parameters for the operation
643
+ - For GET: {"id": "cus_123"} for path params
644
+ - For LIST: {"limit": 10} for query params
645
+ - For CREATE/UPDATE: {"email": "...", "name": "..."} for body
646
+
647
+ Returns:
648
+ API response as dictionary
649
+
650
+ Raises:
651
+ ValueError: If entity or action not found
652
+ HTTPClientError: If API request fails
653
+ """
654
+ params = params or {}
655
+ action = Action(action) if isinstance(action, str) else action
656
+
657
+ # Delegate to the appropriate handler
658
+ handler = next((h for h in self._operation_handlers if h.can_handle(action)), None)
659
+ if not handler:
660
+ raise ExecutorError(f"No handler registered for action '{action.value}'.")
661
+
662
+ # Execute handler and extract just the data for backward compatibility
663
+ result = await handler.execute_operation(entity, action, params)
664
+ if isinstance(result, StandardExecuteResult):
665
+ return result.data
666
+ else:
667
+ # Download operation returns AsyncIterator directly
668
+ return result
669
+
670
+ async def execute_batch(self, operations: list[tuple[str, str | Action, dict[str, Any] | None]]) -> list[dict[str, Any] | AsyncIterator[bytes]]:
671
+ """Execute multiple operations concurrently (supports all action types including download).
672
+
673
+ Args:
674
+ operations: List of (entity, action, params) tuples
675
+
676
+ Returns:
677
+ List of responses in the same order as operations.
678
+ Standard operations return dict[str, Any].
679
+ Download operations return AsyncIterator[bytes].
680
+
681
+ Raises:
682
+ ValueError: If any entity or action not found
683
+ HTTPClientError: If any API request fails
684
+
685
+ Example:
686
+ results = await executor.execute_batch([
687
+ ("Customer", "list", {"limit": 10}),
688
+ ("Customer", "get", {"id": "cus_123"}),
689
+ ("attachments", "download", {"id": "att_456"}),
690
+ ])
691
+ """
692
+ # Build tasks by dispatching directly to handlers
693
+ tasks = []
694
+ for entity, action, params in operations:
695
+ # Convert action to Action enum if needed
696
+ action = Action(action) if isinstance(action, str) else action
697
+ params = params or {}
698
+
699
+ # Find appropriate handler
700
+ handler = next((h for h in self._operation_handlers if h.can_handle(action)), None)
701
+ if not handler:
702
+ raise ExecutorError(f"No handler registered for action '{action.value}'.")
703
+
704
+ # Call handler directly (exceptions propagate naturally)
705
+ tasks.append(handler.execute_operation(entity, action, params))
706
+
707
+ # Execute all tasks concurrently - exceptions propagate via asyncio.gather
708
+ results = await asyncio.gather(*tasks)
709
+
710
+ # Extract data from results
711
+ extracted_results = []
712
+ for result in results:
713
+ if isinstance(result, StandardExecuteResult):
714
+ # Standard operation: extract data
715
+ extracted_results.append(result.data)
716
+ else:
717
+ # Download operation: return iterator as-is
718
+ extracted_results.append(result)
719
+
720
+ return extracted_results
721
+
722
+ def _build_path(self, path_template: str, params: dict[str, Any]) -> str:
723
+ """Build path by replacing {param} placeholders with URL-encoded values.
724
+
725
+ Args:
726
+ path_template: Path with placeholders (e.g., /v1/customers/{id})
727
+ params: Parameters containing values for placeholders
728
+
729
+ Returns:
730
+ Completed path with URL-encoded values (e.g., /v1/customers/cus_123)
731
+
732
+ Raises:
733
+ MissingParameterError: If required path parameter is missing
734
+ """
735
+ placeholders = re.findall(r"\{(\w+)\}", path_template)
736
+
737
+ path = path_template
738
+ for placeholder in placeholders:
739
+ if placeholder not in params:
740
+ raise MissingParameterError(
741
+ f"Missing required path parameter '{placeholder}' for path '{path_template}'. Provided parameters: {list(params.keys())}"
742
+ )
743
+
744
+ # Validate parameter value is not None or empty string
745
+ value = params[placeholder]
746
+ if value is None or (isinstance(value, str) and value.strip() == ""):
747
+ raise InvalidParameterError(f"Path parameter '{placeholder}' cannot be None or empty string")
748
+
749
+ encoded_value = quote(str(value), safe="")
750
+ path = path.replace(f"{{{placeholder}}}", encoded_value)
751
+
752
+ return path
753
+
754
+ def _extract_query_params(self, allowed_params: list[str], params: dict[str, Any]) -> dict[str, Any]:
755
+ """Extract query parameters from params.
756
+
757
+ Args:
758
+ allowed_params: List of allowed query parameter names
759
+ params: All parameters
760
+
761
+ Returns:
762
+ Dictionary of query parameters
763
+ """
764
+ return {key: value for key, value in params.items() if key in allowed_params}
765
+
766
+ def _extract_body(self, allowed_fields: list[str], params: dict[str, Any]) -> dict[str, Any]:
767
+ """Extract body fields from params, filtering out None values.
768
+
769
+ Args:
770
+ allowed_fields: List of allowed body field names
771
+ params: All parameters
772
+
773
+ Returns:
774
+ Dictionary of body fields with None values filtered out
775
+ """
776
+ return {key: value for key, value in params.items() if key in allowed_fields and value is not None}
777
+
778
+ def _extract_header_params(self, endpoint: EndpointDefinition, params: dict[str, Any], body: dict[str, Any] | None = None) -> dict[str, str]:
779
+ """Extract header parameters from params and schema defaults.
780
+
781
+ Also adds Content-Type header when there's a request body (unless already specified
782
+ as a header parameter in the OpenAPI spec).
783
+
784
+ Args:
785
+ endpoint: Endpoint definition with header_params and header_params_schema
786
+ params: All parameters
787
+ body: Request body (if any) - used to determine if Content-Type should be added
788
+
789
+ Returns:
790
+ Dictionary of header name -> value
791
+ """
792
+ headers: dict[str, str] = {}
793
+
794
+ for header_name in endpoint.header_params:
795
+ # Check if value is provided in params
796
+ if header_name in params and params[header_name] is not None:
797
+ headers[header_name] = str(params[header_name])
798
+ # Otherwise, use default from schema if available
799
+ elif header_name in endpoint.header_params_schema:
800
+ default_value = endpoint.header_params_schema[header_name].get("default")
801
+ if default_value is not None:
802
+ headers[header_name] = str(default_value)
803
+
804
+ # Add Content-Type header when there's a request body, but only if not already
805
+ # specified as a header parameter (which allows custom content types like
806
+ # application/vnd.spCampaign.v3+json)
807
+ if body is not None and endpoint.content_type and "Content-Type" not in headers:
808
+ headers["Content-Type"] = endpoint.content_type.value
809
+
810
+ return headers
811
+
812
+ def _serialize_deep_object_params(self, params: dict[str, Any], deep_object_param_names: list[str]) -> dict[str, Any]:
813
+ """Serialize deepObject parameters to bracket notation format.
814
+
815
+ Converts nested dict parameters to the deepObject format expected by APIs
816
+ like Stripe. For example:
817
+ - Input: {'created': {'gte': 123, 'lte': 456}}
818
+ - Output: {'created[gte]': 123, 'created[lte]': 456}
819
+
820
+ Args:
821
+ params: Query parameters dict (may contain nested dicts)
822
+ deep_object_param_names: List of parameter names that use deepObject style
823
+
824
+ Returns:
825
+ Dictionary with deepObject params serialized to bracket notation
826
+ """
827
+ serialized = {}
828
+
829
+ for key, value in params.items():
830
+ if key in deep_object_param_names and isinstance(value, dict):
831
+ # Serialize nested dict to bracket notation
832
+ for subkey, subvalue in value.items():
833
+ if subvalue is not None: # Skip None values
834
+ serialized[f"{key}[{subkey}]"] = subvalue
835
+ else:
836
+ # Keep non-deepObject params as-is (already validated by _extract_query_params)
837
+ serialized[key] = value
838
+
839
+ return serialized
840
+
841
+ @staticmethod
842
+ def _extract_download_url(
843
+ response: dict[str, Any],
844
+ file_field: str,
845
+ entity: str,
846
+ ) -> str:
847
+ """Extract download URL from metadata response using x-airbyte-file-url.
848
+
849
+ Supports both simple dot notation (e.g., "article.content_url") and array
850
+ indexing with bracket notation (e.g., "calls[0].media.audioUrl").
851
+
852
+ Args:
853
+ response: Metadata response containing file reference
854
+ file_field: JSON path to file URL field (from x-airbyte-file-url)
855
+ entity: Entity name (for error messages)
856
+
857
+ Returns:
858
+ Extracted file URL
859
+
860
+ Raises:
861
+ ExecutorError: If file field not found or invalid
862
+ """
863
+ # Navigate nested path (e.g., "article_attachment.content_url" or "calls[0].media.audioUrl")
864
+ parts = file_field.split(".")
865
+ current = response
866
+
867
+ for i, part in enumerate(parts):
868
+ # Check if part has array indexing (e.g., "calls[0]")
869
+ array_match = re.match(r"^(\w+)\[(\d+)\]$", part)
870
+
871
+ if array_match:
872
+ field_name = array_match.group(1)
873
+ index = int(array_match.group(2))
874
+
875
+ # Navigate to the field
876
+ if not isinstance(current, dict):
877
+ raise ExecutorError(
878
+ f"Cannot extract download URL for {entity}: Expected dict at '{'.'.join(parts[:i])}', got {type(current).__name__}"
879
+ )
880
+
881
+ if field_name not in current:
882
+ raise ExecutorError(
883
+ f"Cannot extract download URL for {entity}: "
884
+ f"Field '{field_name}' not found in response. Available fields: {list(current.keys())}"
885
+ )
886
+
887
+ # Get the array
888
+ array_value = current[field_name]
889
+ if not isinstance(array_value, list):
890
+ raise ExecutorError(
891
+ f"Cannot extract download URL for {entity}: Expected list at '{field_name}', got {type(array_value).__name__}"
892
+ )
893
+
894
+ # Check index bounds
895
+ if index >= len(array_value):
896
+ raise ExecutorError(
897
+ f"Cannot extract download URL for {entity}: Index {index} out of bounds for '{field_name}' (length: {len(array_value)})"
898
+ )
899
+
900
+ current = array_value[index]
901
+ else:
902
+ # Regular dict navigation
903
+ if not isinstance(current, dict):
904
+ raise ExecutorError(
905
+ f"Cannot extract download URL for {entity}: Expected dict at '{'.'.join(parts[:i])}', got {type(current).__name__}"
906
+ )
907
+
908
+ if part not in current:
909
+ raise ExecutorError(
910
+ f"Cannot extract download URL for {entity}: Field '{part}' not found in response. Available fields: {list(current.keys())}"
911
+ )
912
+
913
+ current = current[part]
914
+
915
+ if not isinstance(current, str):
916
+ raise ExecutorError(f"Cannot extract download URL for {entity}: Expected string at '{file_field}', got {type(current).__name__}")
917
+
918
+ return current
919
+
920
+ @staticmethod
921
+ def _substitute_file_field_params(
922
+ file_field: str,
923
+ params: dict[str, Any],
924
+ ) -> str:
925
+ """Substitute template variables in file_field with parameter values.
926
+
927
+ Uses Jinja2 with custom delimiters to support OpenAPI-style syntax like
928
+ "attachments[{index}].url" where {index} is replaced with params["index"].
929
+
930
+ Args:
931
+ file_field: File field path with optional template variables
932
+ params: Parameters from execute() call
933
+
934
+ Returns:
935
+ File field with template variables substituted
936
+
937
+ Example:
938
+ >>> _substitute_file_field_params("attachments[{attachment_index}].url", {"attachment_index": 0})
939
+ "attachments[0].url"
940
+ """
941
+
942
+ # Use custom delimiters to match OpenAPI path parameter syntax {var}
943
+ # StrictUndefined raises clear error if a template variable is missing
944
+ env = Environment(
945
+ variable_start_string="{",
946
+ variable_end_string="}",
947
+ undefined=StrictUndefined,
948
+ )
949
+ template = env.from_string(file_field)
950
+ return template.render(params)
951
+
952
+ def _build_request_body(self, endpoint: EndpointDefinition, params: dict[str, Any]) -> dict[str, Any] | None:
953
+ """Build request body (GraphQL or standard).
954
+
955
+ Args:
956
+ endpoint: Endpoint definition
957
+ params: Parameters from execute() call
958
+
959
+ Returns:
960
+ Request body dict or None if no body needed
961
+ """
962
+ if endpoint.graphql_body:
963
+ # Extract defaults from query_params_schema for GraphQL variable interpolation
964
+ param_defaults = {name: schema.get("default") for name, schema in endpoint.query_params_schema.items() if "default" in schema}
965
+ return self._build_graphql_body(endpoint.graphql_body, params, param_defaults)
966
+ elif endpoint.body_fields:
967
+ # Start with defaults from request body schema
968
+ body = dict(endpoint.request_body_defaults)
969
+ # Override with user-provided params (filtering out None values)
970
+ user_body = self._extract_body(endpoint.body_fields, params)
971
+ body.update(user_body)
972
+ return body if body else None
973
+ elif endpoint.request_body_defaults:
974
+ # If no body_fields but we have defaults, return the defaults
975
+ return dict(endpoint.request_body_defaults)
976
+ return None
977
+
978
+ def _flatten_form_data(self, data: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
979
+ """Flatten nested dict/list structures into bracket notation for form encoding.
980
+
981
+ Stripe and similar APIs require nested arrays/objects to be encoded using bracket
982
+ notation when using application/x-www-form-urlencoded content type.
983
+
984
+ Args:
985
+ data: Nested dict with arrays/objects to flatten
986
+ parent_key: Parent key for nested structures (used in recursion)
987
+
988
+ Returns:
989
+ Flattened dict with bracket notation keys
990
+
991
+ Examples:
992
+ >>> _flatten_form_data({"items": [{"price": "p1", "qty": 1}]})
993
+ {"items[0][price]": "p1", "items[0][qty]": 1}
994
+
995
+ >>> _flatten_form_data({"customer": "cus_123", "metadata": {"key": "value"}})
996
+ {"customer": "cus_123", "metadata[key]": "value"}
997
+ """
998
+ flattened = {}
999
+
1000
+ for key, value in data.items():
1001
+ new_key = f"{parent_key}[{key}]" if parent_key else key
1002
+
1003
+ if isinstance(value, dict):
1004
+ # Recursively flatten nested dicts
1005
+ flattened.update(self._flatten_form_data(value, new_key))
1006
+ elif isinstance(value, list):
1007
+ # Flatten arrays with indexed bracket notation
1008
+ for i, item in enumerate(value):
1009
+ indexed_key = f"{new_key}[{i}]"
1010
+ if isinstance(item, dict):
1011
+ # Nested dict in array - recurse
1012
+ flattened.update(self._flatten_form_data(item, indexed_key))
1013
+ elif isinstance(item, list):
1014
+ # Nested list in array - recurse
1015
+ flattened.update(self._flatten_form_data({str(i): item}, new_key))
1016
+ else:
1017
+ # Primitive value in array
1018
+ flattened[indexed_key] = item
1019
+ else:
1020
+ # Primitive value - add directly
1021
+ flattened[new_key] = value
1022
+
1023
+ return flattened
1024
+
1025
+ def _determine_request_format(self, endpoint: EndpointDefinition, body: dict[str, Any] | None) -> dict[str, Any]:
1026
+ """Determine json/data parameters for HTTP request.
1027
+
1028
+ GraphQL always uses JSON, regardless of content_type setting.
1029
+ For form-encoded requests, nested structures are flattened into bracket notation.
1030
+
1031
+ Args:
1032
+ endpoint: Endpoint definition
1033
+ body: Request body dict or None
1034
+
1035
+ Returns:
1036
+ Dict with 'json' and/or 'data' keys for http_client.request()
1037
+ """
1038
+ if not body:
1039
+ return {}
1040
+
1041
+ is_graphql = endpoint.graphql_body is not None
1042
+
1043
+ if is_graphql or endpoint.content_type.value == "application/json":
1044
+ return {"json": body}
1045
+ elif endpoint.content_type.value == "application/x-www-form-urlencoded":
1046
+ # Flatten nested structures for form encoding
1047
+ flattened_body = self._flatten_form_data(body)
1048
+ return {"data": flattened_body}
1049
+
1050
+ return {}
1051
+
1052
+ def _process_graphql_fields(self, query: str, graphql_config: dict[str, Any], params: dict[str, Any]) -> str:
1053
+ """Process GraphQL query field selection.
1054
+
1055
+ Handles:
1056
+ - Dynamic fields from params['fields']
1057
+ - Default fields from config
1058
+ - String vs list format for default_fields
1059
+
1060
+ Args:
1061
+ query: GraphQL query string (may contain {{ fields }} placeholder)
1062
+ graphql_config: GraphQL configuration dict
1063
+ params: Parameters from execute() call
1064
+
1065
+ Returns:
1066
+ Processed query string with fields injected
1067
+ """
1068
+ if "{{ fields }}" not in query:
1069
+ return query
1070
+
1071
+ # Check for explicit fields parameter
1072
+ if "fields" in params and params["fields"]:
1073
+ return self._inject_graphql_fields(query, params["fields"])
1074
+
1075
+ # Use default fields if available
1076
+ if "default_fields" not in graphql_config:
1077
+ return query # Placeholder remains (could raise error in the future)
1078
+
1079
+ default_fields = graphql_config["default_fields"]
1080
+ if isinstance(default_fields, str):
1081
+ # Already in GraphQL format - direct replacement
1082
+ return query.replace("{{ fields }}", default_fields)
1083
+ elif isinstance(default_fields, list):
1084
+ # List format - convert to GraphQL
1085
+ return self._inject_graphql_fields(query, default_fields)
1086
+
1087
+ return query
1088
+
1089
+ def _build_graphql_body(
1090
+ self,
1091
+ graphql_config: dict[str, Any],
1092
+ params: dict[str, Any],
1093
+ param_defaults: dict[str, Any] | None = None,
1094
+ ) -> dict[str, Any]:
1095
+ """Build GraphQL request body with variable substitution and field selection.
1096
+
1097
+ Args:
1098
+ graphql_config: GraphQL configuration from x-airbyte-body-type extension
1099
+ params: Parameters from execute() call
1100
+ param_defaults: Default values for params from query_params_schema
1101
+
1102
+ Returns:
1103
+ GraphQL request body: {"query": "...", "variables": {...}}
1104
+ """
1105
+ query = graphql_config["query"]
1106
+
1107
+ # Process field selection (dynamic fields or default fields)
1108
+ query = self._process_graphql_fields(query, graphql_config, params)
1109
+
1110
+ body = {"query": query}
1111
+
1112
+ # Substitute variables from params
1113
+ if "variables" in graphql_config and graphql_config["variables"]:
1114
+ variables = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
1115
+ # Filter out None values (optional fields not provided) - matches REST _extract_body() behavior
1116
+ # But preserve None for variables explicitly marked as nullable (e.g., to unassign a user)
1117
+ nullable_vars = set(graphql_config.get("x-airbyte-nullable-variables") or [])
1118
+ body["variables"] = {k: v for k, v in variables.items() if v is not None or k in nullable_vars}
1119
+
1120
+ # Add operation name if specified
1121
+ if "operationName" in graphql_config:
1122
+ body["operationName"] = graphql_config["operationName"]
1123
+
1124
+ return body
1125
+
1126
+ def _convert_nested_field_to_graphql(self, field: str) -> str:
1127
+ """Convert dot-notation field to GraphQL field selection.
1128
+
1129
+ Example: "primaryLanguage.name" -> "primaryLanguage { name }"
1130
+
1131
+ Args:
1132
+ field: Field name in dot notation (e.g., "primaryLanguage.name")
1133
+
1134
+ Returns:
1135
+ GraphQL field selection string
1136
+ """
1137
+ if "." not in field:
1138
+ return field
1139
+
1140
+ parts = field.split(".")
1141
+ result = parts[0]
1142
+ for part in parts[1:]:
1143
+ result += f" {{ {part}"
1144
+ result += " }" * (len(parts) - 1)
1145
+ return result
1146
+
1147
+ def _inject_graphql_fields(self, query: str, fields: list[str]) -> str:
1148
+ """Inject field selection into GraphQL query.
1149
+
1150
+ Replaces field selection placeholders ({{ fields }}) with actual field list.
1151
+ Supports nested fields using dot notation (e.g., "primaryLanguage.name").
1152
+
1153
+ Args:
1154
+ query: GraphQL query string (may contain {{ fields }} placeholder)
1155
+ fields: List of fields to select (e.g., ["id", "name", "primaryLanguage.name"])
1156
+
1157
+ Returns:
1158
+ GraphQL query with fields injected
1159
+
1160
+ Example:
1161
+ Input query: "query { repository { {{ fields }} } }"
1162
+ Fields: ["id", "name", "primaryLanguage { name }"]
1163
+ Output: "query { repository { id name primaryLanguage { name } } }"
1164
+ """
1165
+ # Check if query has field placeholder
1166
+ if "{{ fields }}" not in query:
1167
+ # No placeholder - return query as-is (backward compatible)
1168
+ return query
1169
+
1170
+ # Convert field list to GraphQL field selection
1171
+ graphql_fields = [self._convert_nested_field_to_graphql(field) for field in fields]
1172
+
1173
+ # Replace placeholder with field list
1174
+ fields_str = " ".join(graphql_fields)
1175
+ return query.replace("{{ fields }}", fields_str)
1176
+
1177
+ def _interpolate_variables(
1178
+ self,
1179
+ variables: dict[str, Any],
1180
+ params: dict[str, Any],
1181
+ param_defaults: dict[str, Any] | None = None,
1182
+ ) -> dict[str, Any]:
1183
+ """Recursively interpolate variables using params.
1184
+
1185
+ Preserves types (doesn't stringify everything).
1186
+
1187
+ Supports:
1188
+ - Direct replacement: "{{ owner }}" → params["owner"] (preserves type)
1189
+ - Nested objects: {"input": {"name": "{{ name }}"}}
1190
+ - Arrays: [{"id": "{{ id }}"}]
1191
+ - Default values: "{{ per_page }}" → param_defaults["per_page"] if not in params
1192
+ - Unsubstituted placeholders: "{{ states }}" → None (for optional params without defaults)
1193
+
1194
+ Args:
1195
+ variables: Variables dict with template placeholders
1196
+ params: Parameters to substitute
1197
+ param_defaults: Default values for params from query_params_schema
1198
+
1199
+ Returns:
1200
+ Interpolated variables dict with types preserved
1201
+ """
1202
+ defaults = param_defaults or {}
1203
+
1204
+ def interpolate_value(value: Any) -> Any:
1205
+ if isinstance(value, str):
1206
+ # Check for exact template match (preserve type)
1207
+ for key, param_value in params.items():
1208
+ placeholder = f"{{{{ {key} }}}}"
1209
+ if value == placeholder:
1210
+ return param_value # Return actual value (int, list, etc.)
1211
+ elif placeholder in value:
1212
+ # Partial match - do string replacement
1213
+ value = value.replace(placeholder, str(param_value))
1214
+
1215
+ # Check if any unsubstituted placeholders remain
1216
+ if re.search(r"\{\{\s*\w+\s*\}\}", value):
1217
+ # Extract placeholder name and check for default value
1218
+ match = re.search(r"\{\{\s*(\w+)\s*\}\}", value)
1219
+ if match:
1220
+ param_name = match.group(1)
1221
+ if param_name in defaults:
1222
+ # Use default value (preserves type)
1223
+ return defaults[param_name]
1224
+ # No default found - return None (for optional params)
1225
+ return None
1226
+
1227
+ return value
1228
+ elif isinstance(value, dict):
1229
+ return {k: interpolate_value(v) for k, v in value.items()}
1230
+ elif isinstance(value, list):
1231
+ return [interpolate_value(item) for item in value]
1232
+ else:
1233
+ return value
1234
+
1235
+ return interpolate_value(variables)
1236
+
1237
+ def _wrap_primitives(self, data: Any) -> dict[str, Any] | list[dict[str, Any]] | None:
1238
+ """Wrap primitive values in dict format for consistent response structure.
1239
+
1240
+ Transforms primitive API responses into dict format so downstream code
1241
+ can always expect dict-based data structures.
1242
+
1243
+ Args:
1244
+ data: Response data (could be primitive, list, dict, or None)
1245
+
1246
+ Returns:
1247
+ - If data is a primitive (str, int, float, bool): {"value": data}
1248
+ - If data is a list: wraps all non-dict elements as {"value": item}
1249
+ - If data is already a dict or list of dicts: unchanged
1250
+ - If data is None: None
1251
+
1252
+ Examples:
1253
+ >>> executor._wrap_primitives(42)
1254
+ {"value": 42}
1255
+ >>> executor._wrap_primitives([1, 2, 3])
1256
+ [{"value": 1}, {"value": 2}, {"value": 3}]
1257
+ >>> executor._wrap_primitives([1, {"id": 2}, 3])
1258
+ [{"value": 1}, {"id": 2}, {"value": 3}]
1259
+ >>> executor._wrap_primitives([[1, 2], 3])
1260
+ [{"value": [1, 2]}, {"value": 3}]
1261
+ >>> executor._wrap_primitives({"id": 1})
1262
+ {"id": 1} # unchanged
1263
+ """
1264
+ if data is None:
1265
+ return None
1266
+
1267
+ # Handle primitive scalars
1268
+ if isinstance(data, (bool, str, int, float)):
1269
+ return {"value": data}
1270
+
1271
+ # Handle lists - wrap non-dict elements
1272
+ if isinstance(data, list):
1273
+ if not data:
1274
+ return [] # Empty list unchanged
1275
+
1276
+ wrapped = []
1277
+ for item in data:
1278
+ if isinstance(item, dict):
1279
+ wrapped.append(item)
1280
+ else:
1281
+ wrapped.append({"value": item})
1282
+ return wrapped
1283
+
1284
+ # Dict - return unchanged
1285
+ if isinstance(data, dict):
1286
+ return data
1287
+
1288
+ # Unknown type - wrap for safety
1289
+ return {"value": data}
1290
+
1291
+ def _extract_records(
1292
+ self,
1293
+ response_data: Any,
1294
+ endpoint: EndpointDefinition,
1295
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
1296
+ """Extract records from response using record extractor.
1297
+
1298
+ Type inference based on action:
1299
+ - list, search: Returns array ([] if not found)
1300
+ - get, create, update, delete: Returns single record (None if not found)
1301
+
1302
+ Automatically wraps primitive values (int, str, float, bool) in {"value": primitive}
1303
+ format to ensure consistent dict-based responses for downstream code.
1304
+
1305
+ Args:
1306
+ response_data: Full API response (can be dict, list, primitive, or None)
1307
+ endpoint: Endpoint with optional record extractor and action
1308
+
1309
+ Returns:
1310
+ - Extracted data if extractor configured and path found
1311
+ - [] or None if path not found (based on action)
1312
+ - Original response if no extractor configured or on error
1313
+ - Primitives are wrapped as {"value": primitive}
1314
+ """
1315
+ # Check if endpoint has record extractor
1316
+ extractor = endpoint.record_extractor
1317
+ if not extractor:
1318
+ return self._wrap_primitives(response_data)
1319
+
1320
+ # Determine if this action returns array or single record
1321
+ action = endpoint.action
1322
+ if not action:
1323
+ return self._wrap_primitives(response_data)
1324
+
1325
+ is_array_action = action in (Action.LIST, Action.API_SEARCH)
1326
+
1327
+ try:
1328
+ # Parse and apply JSONPath expression
1329
+ jsonpath_expr = parse_jsonpath(extractor)
1330
+ matches = [match.value for match in jsonpath_expr.find(response_data)]
1331
+
1332
+ if not matches:
1333
+ # Path not found - return empty based on action
1334
+ return [] if is_array_action else None
1335
+
1336
+ # Return extracted data with primitive wrapping
1337
+ if is_array_action:
1338
+ # For array actions, return the array (or list of matches)
1339
+ result = matches[0] if len(matches) == 1 else matches
1340
+ else:
1341
+ # For single record actions, return first match
1342
+ result = matches[0]
1343
+
1344
+ return self._wrap_primitives(result)
1345
+
1346
+ except Exception as e:
1347
+ logging.warning(f"Failed to apply record extractor '{extractor}': {e}. Returning original response.")
1348
+ return self._wrap_primitives(response_data)
1349
+
1350
+ def _extract_metadata(
1351
+ self,
1352
+ response_data: dict[str, Any],
1353
+ response_headers: dict[str, str],
1354
+ endpoint: EndpointDefinition,
1355
+ ) -> dict[str, Any] | None:
1356
+ """Extract metadata from response using meta extractor.
1357
+
1358
+ Each field in meta_extractor dict is independently extracted using JSONPath
1359
+ for body extraction, or special prefixes for header extraction:
1360
+ - @link.{rel}: Extract URL from RFC 5988 Link header by rel type
1361
+ - @header.{name}: Extract raw header value by header name
1362
+ - Otherwise: JSONPath expression for body extraction
1363
+
1364
+ Missing or invalid paths result in None for that field (no crash).
1365
+
1366
+ Args:
1367
+ response_data: Full API response (before record extraction)
1368
+ response_headers: HTTP response headers
1369
+ endpoint: Endpoint with optional meta extractor configuration
1370
+
1371
+ Returns:
1372
+ - Dict of extracted metadata if extractor configured
1373
+ - None if no extractor configured
1374
+ - Dict with None values for failed extractions
1375
+
1376
+ Example:
1377
+ meta_extractor = {
1378
+ "pagination": "$.records",
1379
+ "request_id": "$.requestId",
1380
+ "next_page_url": "@link.next",
1381
+ "rate_limit": "@header.X-RateLimit-Remaining"
1382
+ }
1383
+ Returns: {
1384
+ "pagination": {"cursor": "abc", "total": 100},
1385
+ "request_id": "xyz123",
1386
+ "next_page_url": "https://api.example.com/data?cursor=abc",
1387
+ "rate_limit": "99"
1388
+ }
1389
+ """
1390
+ # Check if endpoint has meta extractor
1391
+ if endpoint.meta_extractor is None:
1392
+ return None
1393
+
1394
+ extracted_meta: dict[str, Any] = {}
1395
+
1396
+ # Extract each field independently
1397
+ for field_name, extractor_expr in endpoint.meta_extractor.items():
1398
+ try:
1399
+ if extractor_expr.startswith("@link."):
1400
+ # RFC 5988 Link header extraction
1401
+ rel = extractor_expr[6:]
1402
+ extracted_meta[field_name] = self._extract_link_url(response_headers, rel)
1403
+ elif extractor_expr.startswith("@header."):
1404
+ # Raw header value extraction (case-insensitive lookup)
1405
+ header_name = extractor_expr[8:]
1406
+ extracted_meta[field_name] = self._get_header_value(response_headers, header_name)
1407
+ else:
1408
+ # JSONPath body extraction
1409
+ jsonpath_expr = parse_jsonpath(extractor_expr)
1410
+ matches = [match.value for match in jsonpath_expr.find(response_data)]
1411
+
1412
+ if matches:
1413
+ # Return first match (most common case)
1414
+ extracted_meta[field_name] = matches[0]
1415
+ else:
1416
+ # Path not found - set to None
1417
+ extracted_meta[field_name] = None
1418
+
1419
+ except Exception as e:
1420
+ # Log error but continue with other fields
1421
+ logging.warning(f"Failed to apply meta extractor for field '{field_name}' with expression '{extractor_expr}': {e}. Setting to None.")
1422
+ extracted_meta[field_name] = None
1423
+
1424
+ return extracted_meta
1425
+
1426
+ @staticmethod
1427
+ def _extract_link_url(headers: dict[str, str], rel: str) -> str | None:
1428
+ """Extract URL from RFC 5988 Link header by rel type.
1429
+
1430
+ Parses Link header format: <url>; param1="value1"; rel="next"; param2="value2"
1431
+
1432
+ Supports:
1433
+ - Multiple parameters per link in any order
1434
+ - Both quoted and unquoted rel values
1435
+ - Multiple links separated by commas
1436
+
1437
+ Args:
1438
+ headers: Response headers dict
1439
+ rel: The rel type to extract (e.g., "next", "prev", "first", "last")
1440
+
1441
+ Returns:
1442
+ The URL for the specified rel type, or None if not found
1443
+ """
1444
+ link_header = headers.get("Link") or headers.get("link", "")
1445
+ if not link_header:
1446
+ return None
1447
+
1448
+ for link_segment in re.split(r",(?=\s*<)", link_header):
1449
+ link_segment = link_segment.strip()
1450
+
1451
+ url_match = re.match(r"<([^>]+)>", link_segment)
1452
+ if not url_match:
1453
+ continue
1454
+
1455
+ url = url_match.group(1)
1456
+ params_str = link_segment[url_match.end() :]
1457
+
1458
+ rel_match = re.search(r';\s*rel="?([^";,]+)"?', params_str, re.IGNORECASE)
1459
+ if rel_match and rel_match.group(1).strip() == rel:
1460
+ return url
1461
+
1462
+ return None
1463
+
1464
+ @staticmethod
1465
+ def _get_header_value(headers: dict[str, str], header_name: str) -> str | None:
1466
+ """Get header value with case-insensitive lookup.
1467
+
1468
+ Args:
1469
+ headers: Response headers dict
1470
+ header_name: Header name to look up
1471
+
1472
+ Returns:
1473
+ Header value or None if not found
1474
+ """
1475
+ # Try exact match first
1476
+ if header_name in headers:
1477
+ return headers[header_name]
1478
+
1479
+ # Case-insensitive lookup
1480
+ header_name_lower = header_name.lower()
1481
+ for key, value in headers.items():
1482
+ if key.lower() == header_name_lower:
1483
+ return value
1484
+
1485
+ return None
1486
+
1487
+ def _validate_required_body_fields(self, endpoint: Any, params: dict[str, Any], action: Action, entity: str) -> None:
1488
+ """Validate that required body fields are present for CREATE/UPDATE operations.
1489
+
1490
+ Args:
1491
+ endpoint: Endpoint definition
1492
+ params: Parameters provided
1493
+ action: Operation action
1494
+ entity: Entity name
1495
+
1496
+ Raises:
1497
+ MissingParameterError: If required body fields are missing
1498
+ """
1499
+ # Only validate for operations that typically have required body fields
1500
+ if action not in (Action.CREATE, Action.UPDATE):
1501
+ return
1502
+
1503
+ # Get the request schema to find truly required fields
1504
+ request_schema = endpoint.request_schema
1505
+ if not request_schema:
1506
+ return
1507
+
1508
+ # Only validate fields explicitly marked as required in the schema
1509
+ required_fields = request_schema.get("required", [])
1510
+ missing_fields = [field for field in required_fields if field not in params]
1511
+
1512
+ if missing_fields:
1513
+ raise MissingParameterError(
1514
+ f"Missing required body fields for {entity}.{action.value}: {missing_fields}. Provided parameters: {list(params.keys())}"
1515
+ )
1516
+
1517
+ async def close(self):
1518
+ """Close async HTTP client and logger."""
1519
+ self.tracker.track_session_end()
1520
+ await self.http_client.close()
1521
+ self.logger.close()
1522
+
1523
+ async def __aenter__(self):
1524
+ """Async context manager entry."""
1525
+ return self
1526
+
1527
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
1528
+ """Async context manager exit."""
1529
+ await self.close()
1530
+
1531
+
1532
+ # =============================================================================
1533
+ # Operation Handlers
1534
+ # =============================================================================
1535
+
1536
+
1537
+ class _StandardOperationHandler:
1538
+ """Handler for standard REST operations (GET, LIST, CREATE, UPDATE, DELETE, API_SEARCH, AUTHORIZE)."""
1539
+
1540
+ def __init__(self, context: _OperationContext):
1541
+ self.ctx = context
1542
+
1543
+ def can_handle(self, action: Action) -> bool:
1544
+ """Check if this handler can handle the given action."""
1545
+ return action in {
1546
+ Action.GET,
1547
+ Action.LIST,
1548
+ Action.CREATE,
1549
+ Action.UPDATE,
1550
+ Action.DELETE,
1551
+ Action.API_SEARCH,
1552
+ Action.AUTHORIZE,
1553
+ }
1554
+
1555
+ async def execute_operation(self, entity: str, action: Action, params: dict[str, Any]) -> StandardExecuteResult:
1556
+ """Execute standard REST operation with full telemetry and error handling."""
1557
+ tracer = trace.get_tracer("airbyte.connector-sdk.executor.local")
1558
+
1559
+ with tracer.start_as_current_span("airbyte.local_executor.execute_operation") as span:
1560
+ # Add span attributes
1561
+ span.set_attribute("connector.name", self.ctx.executor.model.name)
1562
+ span.set_attribute("connector.entity", entity)
1563
+ span.set_attribute("connector.action", action.value)
1564
+ if params:
1565
+ span.set_attribute("connector.param_keys", list(params.keys()))
1566
+
1567
+ # Increment operation counter
1568
+ self.ctx.session.increment_operations()
1569
+
1570
+ # Track operation timing and status
1571
+ start_time = time.time()
1572
+ error_type = None
1573
+ status_code = None
1574
+
1575
+ try:
1576
+ # O(1) entity lookup
1577
+ entity_def = self.ctx.entity_index.get(entity)
1578
+ if not entity_def:
1579
+ available_entities = list(self.ctx.entity_index.keys())
1580
+ raise EntityNotFoundError(f"Entity '{entity}' not found in connector. Available entities: {available_entities}")
1581
+
1582
+ # Check if action is supported
1583
+ if action not in entity_def.actions:
1584
+ supported_actions = [a.value for a in entity_def.actions]
1585
+ raise ActionNotSupportedError(
1586
+ f"Action '{action.value}' not supported for entity '{entity}'. Supported actions: {supported_actions}"
1587
+ )
1588
+
1589
+ # O(1) operation lookup
1590
+ endpoint = self.ctx.operation_index.get((entity, action))
1591
+ if not endpoint:
1592
+ raise ExecutorError(f"No endpoint defined for {entity}.{action.value}. This is a configuration error.")
1593
+
1594
+ # Validate required body fields for CREATE/UPDATE operations
1595
+ self.ctx.validate_required_body_fields(endpoint, params, action, entity)
1596
+
1597
+ # Build request parameters
1598
+ # Use path_override if available, otherwise use the OpenAPI path
1599
+ actual_path = endpoint.path_override.path if endpoint.path_override else endpoint.path
1600
+ path = self.ctx.build_path(actual_path, params)
1601
+ query_params = self.ctx.extract_query_params(endpoint.query_params, params)
1602
+
1603
+ # Serialize deepObject parameters to bracket notation
1604
+ if endpoint.deep_object_params:
1605
+ query_params = self.ctx.executor._serialize_deep_object_params(query_params, endpoint.deep_object_params)
1606
+
1607
+ # Build request body (GraphQL or standard)
1608
+ body = self.ctx.build_request_body(endpoint, params)
1609
+
1610
+ # Determine request format (json/data parameters)
1611
+ request_kwargs = self.ctx.determine_request_format(endpoint, body)
1612
+
1613
+ # Extract header parameters from OpenAPI operation (pass body to add Content-Type)
1614
+ header_params = self.ctx.extract_header_params(endpoint, params, body)
1615
+
1616
+ # Execute async HTTP request
1617
+ response_data, response_headers = await self.ctx.http_client.request(
1618
+ method=endpoint.method,
1619
+ path=path,
1620
+ params=query_params if query_params else None,
1621
+ json=request_kwargs.get("json"),
1622
+ data=request_kwargs.get("data"),
1623
+ headers=header_params if header_params else None,
1624
+ )
1625
+
1626
+ # Extract metadata from original response (before record extraction)
1627
+ metadata = self.ctx.executor._extract_metadata(response_data, response_headers, endpoint)
1628
+
1629
+ # Extract records if extractor configured
1630
+ response = self.ctx.extract_records(response_data, endpoint)
1631
+
1632
+ # Assume success with 200 status code if no exception raised
1633
+ status_code = 200
1634
+
1635
+ # Mark span as successful
1636
+ span.set_attribute("connector.success", True)
1637
+ span.set_attribute("http.status_code", status_code)
1638
+
1639
+ # Return StandardExecuteResult with data and metadata
1640
+ return StandardExecuteResult(data=response, metadata=metadata)
1641
+
1642
+ except (EntityNotFoundError, ActionNotSupportedError) as e:
1643
+ # Validation errors - record in span
1644
+ error_type = type(e).__name__
1645
+ span.set_attribute("connector.success", False)
1646
+ span.set_attribute("connector.error_type", error_type)
1647
+ span.record_exception(e)
1648
+ raise
1649
+
1650
+ except Exception as e:
1651
+ # Capture error details
1652
+ error_type = type(e).__name__
1653
+
1654
+ # Try to get status code from HTTP errors
1655
+ if hasattr(e, "response") and hasattr(e.response, "status_code"):
1656
+ status_code = e.response.status_code
1657
+ span.set_attribute("http.status_code", status_code)
1658
+
1659
+ span.set_attribute("connector.success", False)
1660
+ span.set_attribute("connector.error_type", error_type)
1661
+ span.record_exception(e)
1662
+ raise
1663
+
1664
+ finally:
1665
+ # Always track operation (success or failure)
1666
+ timing_ms = (time.time() - start_time) * 1000
1667
+ self.ctx.tracker.track_operation(
1668
+ entity=entity,
1669
+ action=action.value if isinstance(action, Action) else action,
1670
+ status_code=status_code,
1671
+ timing_ms=timing_ms,
1672
+ error_type=error_type,
1673
+ )
1674
+
1675
+
1676
+ class _DownloadOperationHandler:
1677
+ """Handler for download operations.
1678
+
1679
+ Supports two modes:
1680
+ - Two-step (with x-airbyte-file-url): metadata request → extract URL → stream file
1681
+ - One-step (without x-airbyte-file-url): stream file directly from endpoint
1682
+ """
1683
+
1684
+ def __init__(self, context: _OperationContext):
1685
+ self.ctx = context
1686
+
1687
+ def can_handle(self, action: Action) -> bool:
1688
+ """Check if this handler can handle the given action."""
1689
+ return action == Action.DOWNLOAD
1690
+
1691
+ async def execute_operation(self, entity: str, action: Action, params: dict[str, Any]) -> AsyncIterator[bytes]:
1692
+ """Execute download operation (one-step or two-step) with full telemetry."""
1693
+ tracer = trace.get_tracer("airbyte.connector-sdk.executor.local")
1694
+
1695
+ with tracer.start_as_current_span("airbyte.local_executor.execute_operation") as span:
1696
+ # Add span attributes
1697
+ span.set_attribute("connector.name", self.ctx.executor.model.name)
1698
+ span.set_attribute("connector.entity", entity)
1699
+ span.set_attribute("connector.action", action.value)
1700
+ if params:
1701
+ span.set_attribute("connector.param_keys", list(params.keys()))
1702
+
1703
+ # Increment operation counter
1704
+ self.ctx.session.increment_operations()
1705
+
1706
+ # Track operation timing and status
1707
+ start_time = time.time()
1708
+ error_type = None
1709
+ status_code = None
1710
+
1711
+ try:
1712
+ # Look up entity
1713
+ entity_def = self.ctx.entity_index.get(entity)
1714
+ if not entity_def:
1715
+ raise EntityNotFoundError(f"Entity '{entity}' not found in connector. Available entities: {list(self.ctx.entity_index.keys())}")
1716
+
1717
+ # Look up operation
1718
+ operation = self.ctx.operation_index.get((entity, action))
1719
+ if not operation:
1720
+ raise ActionNotSupportedError(
1721
+ f"Action '{action.value}' not supported for entity '{entity}'. Supported actions: {[a.value for a in entity_def.actions]}"
1722
+ )
1723
+
1724
+ # Common setup for both download modes
1725
+ actual_path = operation.path_override.path if operation.path_override else operation.path
1726
+ path = self.ctx.build_path(actual_path, params)
1727
+ query_params = self.ctx.extract_query_params(operation.query_params, params)
1728
+
1729
+ # Serialize deepObject parameters to bracket notation
1730
+ if operation.deep_object_params:
1731
+ query_params = self.ctx.executor._serialize_deep_object_params(query_params, operation.deep_object_params)
1732
+
1733
+ # Prepare headers (with optional Range support)
1734
+ range_header = params.get("range_header")
1735
+ headers = {"Accept": "*/*"}
1736
+ if range_header is not None:
1737
+ headers["Range"] = range_header
1738
+
1739
+ # Check download mode: two-step (with file_field) or one-step (without)
1740
+ file_field = operation.file_field
1741
+
1742
+ if file_field:
1743
+ # Substitute template variables in file_field (e.g., "attachments[{index}].url")
1744
+ file_field = LocalExecutor._substitute_file_field_params(file_field, params)
1745
+
1746
+ if file_field:
1747
+ # Two-step download: metadata → extract URL → stream file
1748
+ # Step 1: Get metadata (standard request)
1749
+ request_body = self.ctx.build_request_body(
1750
+ endpoint=operation,
1751
+ params=params,
1752
+ )
1753
+ request_format = self.ctx.determine_request_format(operation, request_body)
1754
+ self.ctx.validate_required_body_fields(operation, params, action, entity)
1755
+
1756
+ metadata_response, _ = await self.ctx.http_client.request(
1757
+ method=operation.method,
1758
+ path=path,
1759
+ params=query_params,
1760
+ **request_format,
1761
+ )
1762
+
1763
+ # Step 2: Extract file URL from metadata
1764
+ file_url = LocalExecutor._extract_download_url(
1765
+ response=metadata_response,
1766
+ file_field=file_field,
1767
+ entity=entity,
1768
+ )
1769
+
1770
+ # Step 3: Stream file from extracted URL
1771
+ file_response, _ = await self.ctx.http_client.request(
1772
+ method="GET",
1773
+ path=file_url,
1774
+ headers=headers,
1775
+ stream=True,
1776
+ )
1777
+ else:
1778
+ # One-step direct download: stream file directly from endpoint
1779
+ file_response, _ = await self.ctx.http_client.request(
1780
+ method=operation.method,
1781
+ path=path,
1782
+ params=query_params,
1783
+ headers=headers,
1784
+ stream=True,
1785
+ )
1786
+
1787
+ # Assume success once we start streaming
1788
+ status_code = 200
1789
+
1790
+ # Mark span as successful
1791
+ span.set_attribute("connector.success", True)
1792
+ span.set_attribute("http.status_code", status_code)
1793
+
1794
+ # Stream file chunks
1795
+ default_chunk_size = 8 * 1024 * 1024 # 8 MB
1796
+ async for chunk in file_response.original_response.aiter_bytes(chunk_size=default_chunk_size):
1797
+ # Log each chunk for cassette recording
1798
+ self.ctx.logger.log_chunk_fetch(chunk)
1799
+ yield chunk
1800
+
1801
+ except (EntityNotFoundError, ActionNotSupportedError) as e:
1802
+ # Validation errors - record in span
1803
+ error_type = type(e).__name__
1804
+ span.set_attribute("connector.success", False)
1805
+ span.set_attribute("connector.error_type", error_type)
1806
+ span.record_exception(e)
1807
+
1808
+ # Track the failed operation before re-raising
1809
+ timing_ms = (time.time() - start_time) * 1000
1810
+ self.ctx.tracker.track_operation(
1811
+ entity=entity,
1812
+ action=action.value,
1813
+ status_code=status_code,
1814
+ timing_ms=timing_ms,
1815
+ error_type=error_type,
1816
+ )
1817
+ raise
1818
+
1819
+ except Exception as e:
1820
+ # Capture error details
1821
+ error_type = type(e).__name__
1822
+
1823
+ # Try to get status code from HTTP errors
1824
+ if hasattr(e, "response") and hasattr(e.response, "status_code"):
1825
+ status_code = e.response.status_code
1826
+ span.set_attribute("http.status_code", status_code)
1827
+
1828
+ span.set_attribute("connector.success", False)
1829
+ span.set_attribute("connector.error_type", error_type)
1830
+ span.record_exception(e)
1831
+
1832
+ # Track the failed operation before re-raising
1833
+ timing_ms = (time.time() - start_time) * 1000
1834
+ self.ctx.tracker.track_operation(
1835
+ entity=entity,
1836
+ action=action.value,
1837
+ status_code=status_code,
1838
+ timing_ms=timing_ms,
1839
+ error_type=error_type,
1840
+ )
1841
+ raise
1842
+
1843
+ finally:
1844
+ # Track successful operation (if no exception was raised)
1845
+ # Note: For generators, this runs after all chunks are yielded
1846
+ if error_type is None:
1847
+ timing_ms = (time.time() - start_time) * 1000
1848
+ self.ctx.tracker.track_operation(
1849
+ entity=entity,
1850
+ action=action.value,
1851
+ status_code=status_code,
1852
+ timing_ms=timing_ms,
1853
+ error_type=None,
1854
+ )