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