airbyte-agent-mcp 0.1.30__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 (56) hide show
  1. airbyte_agent_mcp/__init__.py +0 -0
  2. airbyte_agent_mcp/__main__.py +26 -0
  3. airbyte_agent_mcp/_vendored/__init__.py +1 -0
  4. airbyte_agent_mcp/_vendored/connector_sdk/__init__.py +82 -0
  5. airbyte_agent_mcp/_vendored/connector_sdk/auth_strategies.py +1123 -0
  6. airbyte_agent_mcp/_vendored/connector_sdk/auth_template.py +135 -0
  7. airbyte_agent_mcp/_vendored/connector_sdk/connector_model_loader.py +938 -0
  8. airbyte_agent_mcp/_vendored/connector_sdk/constants.py +78 -0
  9. airbyte_agent_mcp/_vendored/connector_sdk/exceptions.py +23 -0
  10. airbyte_agent_mcp/_vendored/connector_sdk/executor/__init__.py +31 -0
  11. airbyte_agent_mcp/_vendored/connector_sdk/executor/hosted_executor.py +188 -0
  12. airbyte_agent_mcp/_vendored/connector_sdk/executor/local_executor.py +1504 -0
  13. airbyte_agent_mcp/_vendored/connector_sdk/executor/models.py +190 -0
  14. airbyte_agent_mcp/_vendored/connector_sdk/extensions.py +655 -0
  15. airbyte_agent_mcp/_vendored/connector_sdk/http/__init__.py +37 -0
  16. airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  17. airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  18. airbyte_agent_mcp/_vendored/connector_sdk/http/config.py +98 -0
  19. airbyte_agent_mcp/_vendored/connector_sdk/http/exceptions.py +119 -0
  20. airbyte_agent_mcp/_vendored/connector_sdk/http/protocols.py +114 -0
  21. airbyte_agent_mcp/_vendored/connector_sdk/http/response.py +102 -0
  22. airbyte_agent_mcp/_vendored/connector_sdk/http_client.py +679 -0
  23. airbyte_agent_mcp/_vendored/connector_sdk/logging/__init__.py +11 -0
  24. airbyte_agent_mcp/_vendored/connector_sdk/logging/logger.py +264 -0
  25. airbyte_agent_mcp/_vendored/connector_sdk/logging/types.py +92 -0
  26. airbyte_agent_mcp/_vendored/connector_sdk/observability/__init__.py +11 -0
  27. airbyte_agent_mcp/_vendored/connector_sdk/observability/models.py +19 -0
  28. airbyte_agent_mcp/_vendored/connector_sdk/observability/redactor.py +81 -0
  29. airbyte_agent_mcp/_vendored/connector_sdk/observability/session.py +94 -0
  30. airbyte_agent_mcp/_vendored/connector_sdk/performance/__init__.py +6 -0
  31. airbyte_agent_mcp/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  32. airbyte_agent_mcp/_vendored/connector_sdk/performance/metrics.py +93 -0
  33. airbyte_agent_mcp/_vendored/connector_sdk/schema/__init__.py +75 -0
  34. airbyte_agent_mcp/_vendored/connector_sdk/schema/base.py +160 -0
  35. airbyte_agent_mcp/_vendored/connector_sdk/schema/components.py +238 -0
  36. airbyte_agent_mcp/_vendored/connector_sdk/schema/connector.py +131 -0
  37. airbyte_agent_mcp/_vendored/connector_sdk/schema/extensions.py +109 -0
  38. airbyte_agent_mcp/_vendored/connector_sdk/schema/operations.py +146 -0
  39. airbyte_agent_mcp/_vendored/connector_sdk/schema/security.py +213 -0
  40. airbyte_agent_mcp/_vendored/connector_sdk/secrets.py +182 -0
  41. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  42. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/config.py +32 -0
  43. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/events.py +58 -0
  44. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/tracker.py +151 -0
  45. airbyte_agent_mcp/_vendored/connector_sdk/types.py +239 -0
  46. airbyte_agent_mcp/_vendored/connector_sdk/utils.py +60 -0
  47. airbyte_agent_mcp/_vendored/connector_sdk/validation.py +822 -0
  48. airbyte_agent_mcp/config.py +97 -0
  49. airbyte_agent_mcp/connector_manager.py +340 -0
  50. airbyte_agent_mcp/models.py +147 -0
  51. airbyte_agent_mcp/registry_client.py +103 -0
  52. airbyte_agent_mcp/secret_manager.py +94 -0
  53. airbyte_agent_mcp/server.py +265 -0
  54. airbyte_agent_mcp-0.1.30.dist-info/METADATA +134 -0
  55. airbyte_agent_mcp-0.1.30.dist-info/RECORD +56 -0
  56. airbyte_agent_mcp-0.1.30.dist-info/WHEEL +4 -0
@@ -0,0 +1,938 @@
1
+ """Load and parse connector YAML definitions into ConnectorModel objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import jsonref
10
+ import yaml
11
+ from pydantic import ValidationError
12
+
13
+ from .constants import (
14
+ OPENAPI_DEFAULT_VERSION,
15
+ OPENAPI_VERSION_PREFIX,
16
+ )
17
+
18
+ from .schema import OpenAPIConnector
19
+ from .schema.components import GraphQLBodyConfig, RequestBody
20
+ from .schema.security import AirbyteAuthConfig, AuthConfigFieldSpec
21
+ from .types import (
22
+ Action,
23
+ AuthConfig,
24
+ AuthOption,
25
+ AuthType,
26
+ ConnectorModel,
27
+ ContentType,
28
+ EndpointDefinition,
29
+ EntityDefinition,
30
+ )
31
+
32
+
33
+ class ConnectorModelLoaderError(Exception):
34
+ """Base exception for connector model loading errors."""
35
+
36
+ pass
37
+
38
+
39
+ class InvalidYAMLError(ConnectorModelLoaderError):
40
+ """Raised when YAML syntax is invalid."""
41
+
42
+ pass
43
+
44
+
45
+ class InvalidOpenAPIError(ConnectorModelLoaderError):
46
+ """Raised when OpenAPI specification is invalid."""
47
+
48
+ pass
49
+
50
+
51
+ class DuplicateEntityError(ConnectorModelLoaderError):
52
+ """Raised when duplicate entity names are detected."""
53
+
54
+ pass
55
+
56
+
57
+ class TokenExtractValidationError(ConnectorModelLoaderError):
58
+ """Raised when x-airbyte-token-extract references invalid server variables."""
59
+
60
+ pass
61
+
62
+
63
+ def extract_path_params(path: str) -> list[str]:
64
+ """Extract parameter names from path template.
65
+
66
+ Example: '/v1/customers/{id}/invoices/{invoice_id}' -> ['id', 'invoice_id']
67
+ """
68
+ return re.findall(r"\{(\w+)\}", path)
69
+
70
+
71
+ def resolve_schema_refs(schema: Any, spec_dict: dict) -> dict[str, Any]:
72
+ """Resolve all $ref references in a schema using jsonref.
73
+
74
+ This handles:
75
+ - Simple $refs to components/schemas
76
+ - Nested $refs within schemas
77
+ - Circular references (jsonref handles these gracefully)
78
+
79
+ Args:
80
+ schema: The schema that may contain $refs (can be dict or Pydantic model)
81
+ spec_dict: The full OpenAPI spec as a dict (for reference resolution)
82
+
83
+ Returns:
84
+ Resolved schema as a dictionary with all $refs replaced by their definitions
85
+ """
86
+ if not schema:
87
+ return {}
88
+
89
+ # Convert schema to dict if it's a Pydantic model
90
+ if hasattr(schema, "model_dump"):
91
+ schema_dict = schema.model_dump(by_alias=True, exclude_none=True)
92
+ elif isinstance(schema, dict):
93
+ schema_dict = schema
94
+ else:
95
+ return {}
96
+
97
+ # If there are no $refs, return as-is
98
+ if "$ref" not in str(schema_dict):
99
+ return schema_dict
100
+
101
+ # Use jsonref to resolve all references
102
+ # We need to embed the schema in the spec for proper reference resolution
103
+ temp_spec = spec_dict.copy()
104
+ temp_spec["__temp_schema__"] = schema_dict
105
+
106
+ try:
107
+ # Resolve all references
108
+ resolved_spec = jsonref.replace_refs(
109
+ temp_spec,
110
+ base_uri="",
111
+ jsonschema=True, # Use JSONSchema draft 7 semantics
112
+ lazy_load=False, # Resolve everything immediately
113
+ )
114
+
115
+ # Extract our resolved schema
116
+ resolved_schema = dict(resolved_spec.get("__temp_schema__", {}))
117
+
118
+ # Remove any remaining jsonref proxy objects by converting to plain dict
119
+ return _deproxy_schema(resolved_schema)
120
+ except (jsonref.JsonRefError, KeyError, RecursionError):
121
+ # If resolution fails, return the original schema
122
+ # This allows the system to continue even with malformed $refs
123
+ return schema_dict
124
+
125
+
126
+ def _deproxy_schema(obj: Any) -> Any:
127
+ """Recursively convert jsonref proxy objects to plain dicts/lists.
128
+
129
+ jsonref returns proxy objects that behave like dicts but aren't actual dicts.
130
+ This converts them to plain Python objects for consistent behavior.
131
+ """
132
+ if isinstance(obj, dict) or (hasattr(obj, "__subject__") and hasattr(obj, "keys")):
133
+ # Handle both dicts and jsonref proxy objects
134
+ try:
135
+ return {str(k): _deproxy_schema(v) for k, v in obj.items()}
136
+ except (AttributeError, TypeError):
137
+ return obj
138
+ elif isinstance(obj, (list, tuple)):
139
+ return [_deproxy_schema(item) for item in obj]
140
+ else:
141
+ return obj
142
+
143
+
144
+ def parse_openapi_spec(raw_config: dict) -> OpenAPIConnector:
145
+ """Parse OpenAPI specification from YAML.
146
+
147
+ Args:
148
+ raw_config: Raw YAML configuration
149
+
150
+ Returns:
151
+ Parsed OpenAPIConnector with full validation
152
+
153
+ Raises:
154
+ InvalidOpenAPIError: If OpenAPI spec is invalid or missing required fields
155
+ """
156
+ # Validate OpenAPI version
157
+ openapi_version = raw_config.get("openapi", "")
158
+ if not openapi_version:
159
+ raise InvalidOpenAPIError("Missing required field: 'openapi' version")
160
+
161
+ # Check if version is 3.1.x (we don't support 2.x or 3.0.x)
162
+ if not openapi_version.startswith(OPENAPI_VERSION_PREFIX):
163
+ raise InvalidOpenAPIError(f"Unsupported OpenAPI version: {openapi_version}. Only {OPENAPI_VERSION_PREFIX}x is supported.")
164
+
165
+ # Validate required top-level fields
166
+ if "info" not in raw_config:
167
+ raise InvalidOpenAPIError("Missing required field: 'info'")
168
+
169
+ if "paths" not in raw_config:
170
+ raise InvalidOpenAPIError("Missing required field: 'paths'")
171
+
172
+ # Validate paths is not empty
173
+ if not raw_config["paths"]:
174
+ raise InvalidOpenAPIError("OpenAPI spec must have at least one path definition")
175
+
176
+ # Parse with Pydantic validation
177
+ try:
178
+ spec = OpenAPIConnector(**raw_config)
179
+ except ValidationError as e:
180
+ raise InvalidOpenAPIError(f"OpenAPI validation failed: {e}")
181
+
182
+ return spec
183
+
184
+
185
+ def _extract_request_body_config(
186
+ request_body: RequestBody | None, spec_dict: dict[str, Any]
187
+ ) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None]:
188
+ """Extract request body configuration (GraphQL or standard).
189
+
190
+ Args:
191
+ request_body: RequestBody object from OpenAPI operation
192
+ spec_dict: Full OpenAPI spec dict for $ref resolution
193
+
194
+ Returns:
195
+ Tuple of (body_fields, request_schema, graphql_body)
196
+ - body_fields: List of field names for standard JSON/form bodies
197
+ - request_schema: Resolved request schema dict (for standard bodies)
198
+ - graphql_body: GraphQL body configuration dict (for GraphQL bodies)
199
+ """
200
+ body_fields: list[str] = []
201
+ request_schema: dict[str, Any] | None = None
202
+ graphql_body: dict[str, Any] | None = None
203
+
204
+ if not request_body:
205
+ return body_fields, request_schema, graphql_body
206
+
207
+ # Check for GraphQL extension and extract GraphQL body configuration
208
+ if request_body.x_airbyte_body_type:
209
+ body_type_config = request_body.x_airbyte_body_type
210
+
211
+ # Check if it's GraphQL type (it's a GraphQLBodyConfig Pydantic model)
212
+ if isinstance(body_type_config, GraphQLBodyConfig):
213
+ # Convert Pydantic model to dict, excluding None values
214
+ graphql_body = body_type_config.model_dump(exclude_none=True, by_alias=False)
215
+ return body_fields, request_schema, graphql_body
216
+
217
+ # Parse standard request body
218
+ for content_type_key, media_type in request_body.content.items():
219
+ # media_type is now a MediaType object with schema_ field
220
+ schema = media_type.schema_ or {}
221
+
222
+ # Resolve all $refs in the schema using jsonref
223
+ request_schema = resolve_schema_refs(schema, spec_dict)
224
+
225
+ # Extract body field names from resolved schema
226
+ if isinstance(request_schema, dict) and "properties" in request_schema:
227
+ body_fields = list(request_schema["properties"].keys())
228
+
229
+ return body_fields, request_schema, graphql_body
230
+
231
+
232
+ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel:
233
+ """Convert OpenAPI spec to ConnectorModel format.
234
+
235
+ Args:
236
+ spec: OpenAPI connector specification (fully validated)
237
+
238
+ Returns:
239
+ ConnectorModel with entities and endpoints
240
+ """
241
+ # Validate x-airbyte-token-extract against server variables
242
+ _validate_token_extract(spec)
243
+
244
+ # Convert spec to dict for jsonref resolution
245
+ spec_dict = spec.model_dump(by_alias=True, exclude_none=True)
246
+
247
+ # Extract connector name and version
248
+ name = spec.info.x_airbyte_connector_name or spec.info.title.lower().replace(" ", "-")
249
+ version = spec.info.version
250
+
251
+ # Parse authentication first to get token_extract fields
252
+ auth_config = _parse_auth_from_openapi(spec)
253
+
254
+ # Extract base URL from servers - keep variable placeholders intact
255
+ # Variables will be substituted at runtime by HTTPClient using:
256
+ # - config_values: for user-provided values like subdomain
257
+ # - token_extract: for OAuth dynamic values like instance_url
258
+ # DO NOT substitute defaults here - that would prevent runtime substitution
259
+ base_url = ""
260
+ if spec.servers:
261
+ base_url = spec.servers[0].url
262
+
263
+ # Group operations by entity
264
+ entities_map: dict[str, dict[str, EndpointDefinition]] = {}
265
+
266
+ for path, path_item in spec.paths.items():
267
+ # Check each HTTP method
268
+ for method_name in ["get", "post", "put", "delete", "patch"]:
269
+ operation = getattr(path_item, method_name, None)
270
+ if not operation:
271
+ continue
272
+
273
+ # Extract entity and action from x-airbyte-entity and x-airbyte-action
274
+ entity_name = operation.x_airbyte_entity
275
+ action_name = operation.x_airbyte_action
276
+ path_override = operation.x_airbyte_path_override
277
+ record_extractor = operation.x_airbyte_record_extractor
278
+ meta_extractor = operation.x_airbyte_meta_extractor
279
+
280
+ if not entity_name:
281
+ raise InvalidOpenAPIError(
282
+ f"Missing required x-airbyte-entity in operation {method_name.upper()} {path}. All operations must specify an entity."
283
+ )
284
+
285
+ if not action_name:
286
+ raise InvalidOpenAPIError(
287
+ f"Missing required x-airbyte-action in operation {method_name.upper()} {path}. All operations must specify an action."
288
+ )
289
+
290
+ # Convert to Action enum
291
+ try:
292
+ action = Action(action_name)
293
+ except ValueError:
294
+ # Provide clear error for invalid actions
295
+ valid_actions = ", ".join([a.value for a in Action])
296
+ raise InvalidOpenAPIError(
297
+ f"Invalid action '{action_name}' in operation {method_name.upper()} {path}. Valid actions are: {valid_actions}"
298
+ )
299
+
300
+ # Determine content type
301
+ content_type = ContentType.JSON
302
+ if operation.request_body and operation.request_body.content:
303
+ if "application/x-www-form-urlencoded" in operation.request_body.content:
304
+ content_type = ContentType.FORM_URLENCODED
305
+ elif "multipart/form-data" in operation.request_body.content:
306
+ content_type = ContentType.FORM_DATA
307
+
308
+ # Extract parameters with their schemas (including defaults)
309
+ path_params: list[str] = []
310
+ path_params_schema: dict[str, dict[str, Any]] = {}
311
+ query_params: list[str] = []
312
+ query_params_schema: dict[str, dict[str, Any]] = {}
313
+ deep_object_params: list[str] = []
314
+
315
+ if operation.parameters:
316
+ for param in operation.parameters:
317
+ param_schema = param.schema_ or {}
318
+ schema_info = {
319
+ "type": param_schema.get("type", "string"),
320
+ "required": param.required or False,
321
+ "default": param_schema.get("default"),
322
+ }
323
+
324
+ if param.in_ == "path":
325
+ path_params.append(param.name)
326
+ # Path params are always required
327
+ schema_info["required"] = True
328
+ path_params_schema[param.name] = schema_info
329
+ elif param.in_ == "query":
330
+ query_params.append(param.name)
331
+ query_params_schema[param.name] = schema_info
332
+ # Check if this is a deepObject style parameter
333
+ if hasattr(param, "style") and param.style == "deepObject":
334
+ deep_object_params.append(param.name)
335
+
336
+ # Extract body fields from request schema
337
+ body_fields, request_schema, graphql_body = _extract_request_body_config(operation.request_body, spec_dict)
338
+
339
+ # Extract response schema
340
+ response_schema = None
341
+ if "200" in operation.responses:
342
+ response = operation.responses["200"]
343
+ if response.content and "application/json" in response.content:
344
+ media_type = response.content["application/json"]
345
+ schema = media_type.schema_ if media_type else {}
346
+
347
+ # Resolve all $refs in the response schema using jsonref
348
+ response_schema = resolve_schema_refs(schema, spec_dict)
349
+
350
+ # Extract file_field for download operations
351
+ file_field = getattr(operation, "x_airbyte_file_url", None)
352
+
353
+ # Extract untested flag
354
+ untested = getattr(operation, "x_airbyte_untested", None) or False
355
+
356
+ # Create endpoint definition
357
+ endpoint = EndpointDefinition(
358
+ method=method_name.upper(),
359
+ action=action,
360
+ path=path,
361
+ path_override=path_override,
362
+ record_extractor=record_extractor,
363
+ meta_extractor=meta_extractor,
364
+ description=operation.description or operation.summary,
365
+ body_fields=body_fields,
366
+ query_params=query_params,
367
+ query_params_schema=query_params_schema,
368
+ deep_object_params=deep_object_params,
369
+ path_params=path_params,
370
+ path_params_schema=path_params_schema,
371
+ content_type=content_type,
372
+ request_schema=request_schema,
373
+ response_schema=response_schema,
374
+ graphql_body=graphql_body,
375
+ file_field=file_field,
376
+ untested=untested,
377
+ )
378
+
379
+ # Add to entities map
380
+ if entity_name not in entities_map:
381
+ entities_map[entity_name] = {}
382
+ entities_map[entity_name][action] = endpoint
383
+
384
+ # Note: No need to check for duplicate entity names - the dict structure
385
+ # automatically ensures uniqueness. If the OpenAPI spec contains duplicate
386
+ # operationIds, only the last one will be kept.
387
+
388
+ # Convert entities map to EntityDefinition list
389
+ entities = []
390
+ for entity_name, endpoints_dict in entities_map.items():
391
+ actions = list(endpoints_dict.keys())
392
+
393
+ # Get schema from components if available
394
+ schema = None
395
+ if spec.components:
396
+ # Look for a schema matching the entity name
397
+ for schema_name, schema_def in spec.components.schemas.items():
398
+ if schema_def.x_airbyte_entity_name == entity_name or schema_name.lower() == entity_name.lower():
399
+ schema = schema_def.model_dump(by_alias=True)
400
+ break
401
+
402
+ entity = EntityDefinition(name=entity_name, actions=actions, endpoints=endpoints_dict, schema=schema)
403
+ entities.append(entity)
404
+
405
+ # Extract retry config from x-airbyte-retry-config extension
406
+ retry_config = spec.info.x_airbyte_retry_config
407
+
408
+ # Create ConnectorModel
409
+ model = ConnectorModel(
410
+ name=name,
411
+ version=version,
412
+ base_url=base_url,
413
+ auth=auth_config,
414
+ entities=entities,
415
+ openapi_spec=spec,
416
+ retry_config=retry_config,
417
+ )
418
+
419
+ return model
420
+
421
+
422
+ def _get_attribute_flexible(obj: Any, *names: str) -> Any:
423
+ """Get attribute from object, trying multiple name variants.
424
+
425
+ Supports both snake_case and camelCase attribute names.
426
+ Returns None if no variant is found.
427
+
428
+ Args:
429
+ obj: Object to get attribute from
430
+ *names: Attribute names to try in order
431
+
432
+ Returns:
433
+ Attribute value if found, None otherwise
434
+
435
+ Example:
436
+ # Try both "refresh_url" and "refreshUrl"
437
+ url = _get_attribute_flexible(flow, "refresh_url", "refreshUrl")
438
+ """
439
+ for name in names:
440
+ value = getattr(obj, name, None)
441
+ if value is not None:
442
+ return value
443
+ return None
444
+
445
+
446
+ def _select_oauth2_flow(flows: Any) -> Any:
447
+ """Select the best OAuth2 flow from available flows.
448
+
449
+ Prefers authorizationCode (most secure for web apps), but falls back
450
+ to other flow types if not available.
451
+
452
+ Args:
453
+ flows: OAuth2 flows object from OpenAPI spec
454
+
455
+ Returns:
456
+ Selected flow object, or None if no flows available
457
+ """
458
+ # Priority order: authorizationCode > clientCredentials > password > implicit
459
+ flow_names = [
460
+ ("authorization_code", "authorizationCode"), # Preferred
461
+ ("client_credentials", "clientCredentials"), # Server-to-server
462
+ ("password", "password"), # Resource owner
463
+ ("implicit", "implicit"), # Legacy, less secure
464
+ ]
465
+
466
+ for snake_case, camel_case in flow_names:
467
+ flow = _get_attribute_flexible(flows, snake_case, camel_case)
468
+ if flow:
469
+ return flow
470
+
471
+ return None
472
+
473
+
474
+ def _parse_oauth2_config(scheme: Any) -> dict[str, str]:
475
+ """Parse OAuth2 authentication configuration from OpenAPI scheme.
476
+
477
+ Extracts configuration from standard OAuth2 flows and custom x-airbyte-token-refresh
478
+ extension for additional refresh behavior customization.
479
+
480
+ Args:
481
+ scheme: OAuth2 security scheme from OpenAPI spec
482
+
483
+ Returns:
484
+ Dictionary with OAuth2 configuration including:
485
+ - header: Authorization header name (default: "Authorization")
486
+ - prefix: Token prefix (default: "Bearer")
487
+ - refresh_url: Token refresh endpoint (from flows)
488
+ - auth_style: How to send credentials (from x-airbyte-token-refresh)
489
+ - body_format: Request encoding (from x-airbyte-token-refresh)
490
+ """
491
+ config: dict[str, str] = {
492
+ "header": "Authorization",
493
+ "prefix": "Bearer",
494
+ }
495
+
496
+ # Extract flow information for refresh_url
497
+ if scheme.flows:
498
+ flow = _select_oauth2_flow(scheme.flows)
499
+ if flow:
500
+ # Try to get refresh URL (supports both naming conventions)
501
+ refresh_url = _get_attribute_flexible(flow, "refresh_url", "refreshUrl")
502
+ if refresh_url:
503
+ config["refresh_url"] = refresh_url
504
+
505
+ # Extract custom refresh configuration from x-airbyte-token-refresh extension
506
+ x_token_refresh = getattr(scheme, "x_token_refresh", None)
507
+ if x_token_refresh:
508
+ auth_style = getattr(x_token_refresh, "auth_style", None)
509
+ if auth_style:
510
+ config["auth_style"] = auth_style
511
+
512
+ body_format = getattr(x_token_refresh, "body_format", None)
513
+ if body_format:
514
+ config["body_format"] = body_format
515
+
516
+ # Extract token_extract fields from x-airbyte-token-extract extension
517
+ x_token_extract = getattr(scheme, "x_airbyte_token_extract", None)
518
+ if x_token_extract:
519
+ config["token_extract"] = x_token_extract
520
+
521
+ return config
522
+
523
+
524
+ def _validate_token_extract(spec: OpenAPIConnector) -> None:
525
+ """Validate x-airbyte-token-extract against server variables.
526
+
527
+ Ensures that fields specified in x-airbyte-token-extract match defined
528
+ server variables. This catches configuration errors at load time rather
529
+ than at runtime during token refresh.
530
+
531
+ Args:
532
+ spec: OpenAPI connector specification
533
+
534
+ Raises:
535
+ TokenExtractValidationError: If token_extract fields don't match server variables
536
+ """
537
+ # Get server variables
538
+ server_variables: set[str] = set()
539
+ if spec.servers:
540
+ for server in spec.servers:
541
+ if server.variables:
542
+ server_variables.update(server.variables.keys())
543
+
544
+ # Get token_extract from security scheme
545
+ if not spec.components or not spec.components.security_schemes:
546
+ return
547
+
548
+ for scheme_name, scheme in spec.components.security_schemes.items():
549
+ if scheme.type != "oauth2":
550
+ continue
551
+
552
+ token_extract = getattr(scheme, "x_airbyte_token_extract", None)
553
+ if not token_extract:
554
+ continue
555
+
556
+ # Validate each field matches a server variable
557
+ for field in token_extract:
558
+ if field not in server_variables:
559
+ raise TokenExtractValidationError(
560
+ f"x-airbyte-token-extract field '{field}' does not match any defined "
561
+ f"server variable. Available server variables: {sorted(server_variables) or 'none'}. "
562
+ f"Please define '{{{field}}}' in your server URL and add a variable definition."
563
+ )
564
+
565
+
566
+ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
567
+ """Generate default x-airbyte-auth-config for an auth type.
568
+
569
+ When x-airbyte-auth-config is not explicitly defined in the OpenAPI spec,
570
+ we generate a sensible default that maps user-friendly field names to
571
+ the auth scheme's parameters.
572
+
573
+ Args:
574
+ auth_type: The authentication type (BEARER, BASIC, API_KEY)
575
+
576
+ Returns:
577
+ Default auth config spec with properties and auth_mapping
578
+ """
579
+ if auth_type == AuthType.BEARER:
580
+ return AirbyteAuthConfig(
581
+ title=None,
582
+ description=None,
583
+ type="object",
584
+ required=["token"],
585
+ properties={
586
+ "token": AuthConfigFieldSpec(
587
+ type="string",
588
+ title="Bearer Token",
589
+ description="Authentication bearer token",
590
+ format=None,
591
+ pattern=None,
592
+ airbyte_secret=False,
593
+ default=None,
594
+ )
595
+ },
596
+ auth_mapping={"token": "${token}"},
597
+ oneOf=None,
598
+ )
599
+ elif auth_type == AuthType.BASIC:
600
+ return AirbyteAuthConfig(
601
+ title=None,
602
+ description=None,
603
+ type="object",
604
+ required=["username", "password"],
605
+ properties={
606
+ "username": AuthConfigFieldSpec(
607
+ type="string",
608
+ title="Username",
609
+ description="Authentication username",
610
+ format=None,
611
+ pattern=None,
612
+ airbyte_secret=False,
613
+ default=None,
614
+ ),
615
+ "password": AuthConfigFieldSpec(
616
+ type="string",
617
+ title="Password",
618
+ description="Authentication password",
619
+ format=None,
620
+ pattern=None,
621
+ airbyte_secret=False,
622
+ default=None,
623
+ ),
624
+ },
625
+ auth_mapping={"username": "${username}", "password": "${password}"},
626
+ oneOf=None,
627
+ )
628
+ elif auth_type == AuthType.API_KEY:
629
+ return AirbyteAuthConfig(
630
+ title=None,
631
+ description=None,
632
+ type="object",
633
+ required=["api_key"],
634
+ properties={
635
+ "api_key": AuthConfigFieldSpec(
636
+ type="string",
637
+ title="API Key",
638
+ description="API authentication key",
639
+ format=None,
640
+ pattern=None,
641
+ airbyte_secret=False,
642
+ default=None,
643
+ )
644
+ },
645
+ auth_mapping={"api_key": "${api_key}"},
646
+ oneOf=None,
647
+ )
648
+ elif auth_type == AuthType.OAUTH2:
649
+ # OAuth2: No fields are strictly required to support both modes:
650
+ # 1. Full token mode: user provides access_token (and optionally refresh credentials)
651
+ # 2. Refresh-token-only mode: user provides refresh_token, client_id, client_secret
652
+ # The auth_mapping includes all fields, but apply_auth_mapping
653
+ # will skip mappings for fields not provided by the user.
654
+ return AirbyteAuthConfig(
655
+ title=None,
656
+ description=None,
657
+ type="object",
658
+ required=[],
659
+ properties={
660
+ "access_token": AuthConfigFieldSpec(
661
+ type="string",
662
+ title="Access Token",
663
+ description="OAuth2 access token",
664
+ format=None,
665
+ pattern=None,
666
+ airbyte_secret=False,
667
+ default=None,
668
+ ),
669
+ "refresh_token": AuthConfigFieldSpec(
670
+ type="string",
671
+ title="Refresh Token",
672
+ description="OAuth2 refresh token (optional)",
673
+ format=None,
674
+ pattern=None,
675
+ airbyte_secret=False,
676
+ default=None,
677
+ ),
678
+ "client_id": AuthConfigFieldSpec(
679
+ type="string",
680
+ title="Client ID",
681
+ description="OAuth2 client ID (optional)",
682
+ format=None,
683
+ pattern=None,
684
+ airbyte_secret=False,
685
+ default=None,
686
+ ),
687
+ "client_secret": AuthConfigFieldSpec(
688
+ type="string",
689
+ title="Client Secret",
690
+ description="OAuth2 client secret (optional)",
691
+ format=None,
692
+ pattern=None,
693
+ airbyte_secret=False,
694
+ default=None,
695
+ ),
696
+ },
697
+ auth_mapping={
698
+ "access_token": "${access_token}",
699
+ "refresh_token": "${refresh_token}",
700
+ "client_id": "${client_id}",
701
+ "client_secret": "${client_secret}",
702
+ },
703
+ oneOf=None,
704
+ )
705
+ else:
706
+ # Unknown auth type - return minimal config
707
+ return AirbyteAuthConfig(
708
+ title=None,
709
+ description=None,
710
+ type="object",
711
+ required=None,
712
+ properties={},
713
+ auth_mapping={},
714
+ oneOf=None,
715
+ )
716
+
717
+
718
+ def _parse_auth_from_openapi(spec: OpenAPIConnector) -> AuthConfig:
719
+ """Parse authentication configuration from OpenAPI spec.
720
+
721
+ Supports both single and multiple security schemes. For backwards compatibility,
722
+ single-scheme connectors continue to use the legacy AuthConfig format.
723
+ If no security schemes are defined, generates a default Bearer auth config.
724
+
725
+ Args:
726
+ spec: OpenAPI connector specification
727
+
728
+ Returns:
729
+ AuthConfig with either single or multiple auth options
730
+ """
731
+ if not spec.components or not spec.components.security_schemes:
732
+ # Backwards compatibility: generate default Bearer auth when no schemes defined
733
+ default_config = _generate_default_auth_config(AuthType.BEARER)
734
+ return AuthConfig(
735
+ type=AuthType.BEARER,
736
+ config={"header": "Authorization", "prefix": "Bearer"},
737
+ user_config_spec=default_config,
738
+ options=None,
739
+ )
740
+
741
+ schemes = spec.components.security_schemes
742
+
743
+ # Single scheme: backwards compatible mode
744
+ if len(schemes) == 1:
745
+ scheme_name, scheme = next(iter(schemes.items()))
746
+ return _parse_single_security_scheme(scheme)
747
+
748
+ # Multiple schemes: new multi-auth mode
749
+ options = []
750
+ for scheme_name, scheme in schemes.items():
751
+ try:
752
+ auth_option = _parse_security_scheme_to_option(scheme_name, scheme)
753
+ options.append(auth_option)
754
+ except Exception as e:
755
+ # Log warning but continue - skip invalid schemes
756
+ import logging
757
+
758
+ logger = logging.getLogger(__name__)
759
+ logger.warning(f"Skipping invalid security scheme '{scheme_name}': {e}")
760
+ continue
761
+
762
+ if not options:
763
+ raise InvalidOpenAPIError("No valid security schemes found. Connector must define at least one valid security scheme.")
764
+
765
+ return AuthConfig(
766
+ type=None,
767
+ config={},
768
+ user_config_spec=None,
769
+ options=options,
770
+ )
771
+
772
+
773
+ def _parse_single_security_scheme(scheme: Any) -> AuthConfig:
774
+ """Parse a single security scheme into AuthConfig.
775
+
776
+ This extracts the existing single-scheme parsing logic for reuse.
777
+
778
+ Args:
779
+ scheme: SecurityScheme from OpenAPI spec
780
+
781
+ Returns:
782
+ AuthConfig in single-auth mode
783
+ """
784
+ auth_type = AuthType.API_KEY # Default
785
+ auth_config = {}
786
+
787
+ if scheme.type == "http":
788
+ if scheme.scheme == "bearer":
789
+ auth_type = AuthType.BEARER
790
+ auth_config = {"header": "Authorization", "prefix": "Bearer"}
791
+ elif scheme.scheme == "basic":
792
+ auth_type = AuthType.BASIC
793
+ auth_config = {}
794
+
795
+ elif scheme.type == "apiKey":
796
+ auth_type = AuthType.API_KEY
797
+ auth_config = {
798
+ "header": scheme.name or "Authorization",
799
+ "in": scheme.in_ or "header",
800
+ }
801
+
802
+ elif scheme.type == "oauth2":
803
+ # Parse OAuth2 configuration
804
+ oauth2_config = _parse_oauth2_config(scheme)
805
+ # Use explicit x-airbyte-auth-config if present, otherwise generate default
806
+ auth_config_obj = scheme.x_airbyte_auth_config or _generate_default_auth_config(AuthType.OAUTH2)
807
+ return AuthConfig(
808
+ type=AuthType.OAUTH2,
809
+ config=oauth2_config,
810
+ user_config_spec=auth_config_obj,
811
+ options=None,
812
+ )
813
+
814
+ # Use explicit x-airbyte-auth-config if present, otherwise generate default
815
+ auth_config_obj = scheme.x_airbyte_auth_config or _generate_default_auth_config(auth_type)
816
+
817
+ return AuthConfig(
818
+ type=auth_type,
819
+ config=auth_config,
820
+ user_config_spec=auth_config_obj,
821
+ options=None,
822
+ )
823
+
824
+
825
+ def _parse_security_scheme_to_option(scheme_name: str, scheme: Any) -> AuthOption:
826
+ """Parse a security scheme into an AuthOption for multi-auth connectors.
827
+
828
+ Args:
829
+ scheme_name: Name of the security scheme (e.g., "githubOAuth")
830
+ scheme: SecurityScheme from OpenAPI spec
831
+
832
+ Returns:
833
+ AuthOption containing the parsed configuration
834
+
835
+ Raises:
836
+ ValueError: If scheme is invalid or unsupported
837
+ """
838
+ # Parse using existing single-scheme logic
839
+ single_auth = _parse_single_security_scheme(scheme)
840
+
841
+ # Convert to AuthOption
842
+ return AuthOption(
843
+ scheme_name=scheme_name,
844
+ type=single_auth.type,
845
+ config=single_auth.config,
846
+ user_config_spec=single_auth.user_config_spec,
847
+ )
848
+
849
+
850
+ def load_connector_model(definition_path: str | Path) -> ConnectorModel:
851
+ """Load connector model from YAML definition file.
852
+
853
+ Supports both OpenAPI 3.1 format and legacy format.
854
+
855
+ Args:
856
+ definition_path: Path to connector.yaml file
857
+
858
+ Returns:
859
+ Parsed ConnectorModel
860
+
861
+ Raises:
862
+ FileNotFoundError: If definition file doesn't exist
863
+ ValueError: If YAML is invalid
864
+ """
865
+ definition_path = Path(definition_path)
866
+
867
+ if not definition_path.exists():
868
+ raise FileNotFoundError(f"Connector definition not found: {definition_path}")
869
+
870
+ # Load YAML with error handling
871
+ try:
872
+ with open(definition_path) as f:
873
+ raw_definition = yaml.safe_load(f)
874
+ except yaml.YAMLError as e:
875
+ raise InvalidYAMLError(f"Invalid YAML syntax in {definition_path}: {e}")
876
+ except Exception as e:
877
+ raise ConnectorModelLoaderError(f"Error reading definition file {definition_path}: {e}")
878
+
879
+ if not raw_definition:
880
+ raise ValueError("Invalid connector.yaml: empty file")
881
+
882
+ # Detect format: OpenAPI if 'openapi' key exists
883
+ if "openapi" in raw_definition:
884
+ spec = parse_openapi_spec(raw_definition)
885
+ return convert_openapi_to_connector_model(spec)
886
+
887
+ # Legacy format
888
+ if "connector" not in raw_definition:
889
+ raise ValueError("Invalid connector.yaml: missing 'connector' or 'openapi' key")
890
+
891
+ # Parse connector metadata
892
+ connector_meta = raw_definition["connector"]
893
+
894
+ # Parse auth config
895
+ auth_config = raw_definition.get("auth", {})
896
+
897
+ # Parse entities
898
+ entities = []
899
+ for entity_data in raw_definition.get("entities", []):
900
+ # Parse endpoints for each action
901
+ endpoints_dict = {}
902
+ for action_str in entity_data.get("actions", []):
903
+ action = Action(action_str)
904
+ endpoint_data = entity_data["endpoints"].get(action_str)
905
+
906
+ if endpoint_data:
907
+ # Extract path parameters from the path template
908
+ path_params = extract_path_params(endpoint_data["path"])
909
+
910
+ endpoint = EndpointDefinition(
911
+ method=endpoint_data["method"],
912
+ path=endpoint_data["path"],
913
+ description=endpoint_data.get("description"),
914
+ body_fields=endpoint_data.get("body_fields", []),
915
+ query_params=endpoint_data.get("query_params", []),
916
+ path_params=path_params,
917
+ graphql_body=None, # GraphQL only supported in OpenAPI format (via x-airbyte-body-type)
918
+ )
919
+ endpoints_dict[action] = endpoint
920
+
921
+ entity = EntityDefinition(
922
+ name=entity_data["name"],
923
+ actions=[Action(a) for a in entity_data["actions"]],
924
+ endpoints=endpoints_dict,
925
+ schema=entity_data.get("schema"),
926
+ )
927
+ entities.append(entity)
928
+
929
+ # Build ConnectorModel
930
+ model = ConnectorModel(
931
+ name=connector_meta["name"],
932
+ version=connector_meta.get("version", OPENAPI_DEFAULT_VERSION),
933
+ base_url=raw_definition.get("base_url", connector_meta.get("base_url", "")),
934
+ auth=auth_config,
935
+ entities=entities,
936
+ )
937
+
938
+ return model