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