airbyte-agent-zendesk-support 0.18.47__py3-none-any.whl → 0.18.57__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.
@@ -72,7 +72,33 @@ from .models import (
72
72
  SlaPoliciesListResult,
73
73
  TicketFormsListResult,
74
74
  ArticlesListResult,
75
- ArticleAttachmentsListResult
75
+ ArticleAttachmentsListResult,
76
+ AirbyteSearchHit,
77
+ AirbyteSearchResult,
78
+ BrandsSearchData,
79
+ BrandsSearchResult,
80
+ GroupsSearchData,
81
+ GroupsSearchResult,
82
+ OrganizationsSearchData,
83
+ OrganizationsSearchResult,
84
+ SatisfactionRatingsSearchData,
85
+ SatisfactionRatingsSearchResult,
86
+ TagsSearchData,
87
+ TagsSearchResult,
88
+ TicketAuditsSearchData,
89
+ TicketAuditsSearchResult,
90
+ TicketCommentsSearchData,
91
+ TicketCommentsSearchResult,
92
+ TicketFieldsSearchData,
93
+ TicketFieldsSearchResult,
94
+ TicketFormsSearchData,
95
+ TicketFormsSearchResult,
96
+ TicketMetricsSearchData,
97
+ TicketMetricsSearchResult,
98
+ TicketsSearchData,
99
+ TicketsSearchResult,
100
+ UsersSearchData,
101
+ UsersSearchResult
76
102
  )
77
103
  from .types import (
78
104
  TicketsListParams,
@@ -114,7 +140,45 @@ from .types import (
114
140
  ArticlesGetParams,
115
141
  ArticleAttachmentsListParams,
116
142
  ArticleAttachmentsGetParams,
117
- ArticleAttachmentsDownloadParams
143
+ ArticleAttachmentsDownloadParams,
144
+ AirbyteSearchParams,
145
+ AirbyteSortOrder,
146
+ BrandsSearchFilter,
147
+ BrandsSearchQuery,
148
+ BrandsCondition,
149
+ GroupsSearchFilter,
150
+ GroupsSearchQuery,
151
+ GroupsCondition,
152
+ OrganizationsSearchFilter,
153
+ OrganizationsSearchQuery,
154
+ OrganizationsCondition,
155
+ SatisfactionRatingsSearchFilter,
156
+ SatisfactionRatingsSearchQuery,
157
+ SatisfactionRatingsCondition,
158
+ TagsSearchFilter,
159
+ TagsSearchQuery,
160
+ TagsCondition,
161
+ TicketAuditsSearchFilter,
162
+ TicketAuditsSearchQuery,
163
+ TicketAuditsCondition,
164
+ TicketCommentsSearchFilter,
165
+ TicketCommentsSearchQuery,
166
+ TicketCommentsCondition,
167
+ TicketFieldsSearchFilter,
168
+ TicketFieldsSearchQuery,
169
+ TicketFieldsCondition,
170
+ TicketFormsSearchFilter,
171
+ TicketFormsSearchQuery,
172
+ TicketFormsCondition,
173
+ TicketMetricsSearchFilter,
174
+ TicketMetricsSearchQuery,
175
+ TicketMetricsCondition,
176
+ TicketsSearchFilter,
177
+ TicketsSearchQuery,
178
+ TicketsCondition,
179
+ UsersSearchFilter,
180
+ UsersSearchQuery,
181
+ UsersCondition
118
182
  )
119
183
 
120
184
  __all__ = [
@@ -186,6 +250,32 @@ __all__ = [
186
250
  "TicketFormsListResult",
187
251
  "ArticlesListResult",
188
252
  "ArticleAttachmentsListResult",
253
+ "AirbyteSearchHit",
254
+ "AirbyteSearchResult",
255
+ "BrandsSearchData",
256
+ "BrandsSearchResult",
257
+ "GroupsSearchData",
258
+ "GroupsSearchResult",
259
+ "OrganizationsSearchData",
260
+ "OrganizationsSearchResult",
261
+ "SatisfactionRatingsSearchData",
262
+ "SatisfactionRatingsSearchResult",
263
+ "TagsSearchData",
264
+ "TagsSearchResult",
265
+ "TicketAuditsSearchData",
266
+ "TicketAuditsSearchResult",
267
+ "TicketCommentsSearchData",
268
+ "TicketCommentsSearchResult",
269
+ "TicketFieldsSearchData",
270
+ "TicketFieldsSearchResult",
271
+ "TicketFormsSearchData",
272
+ "TicketFormsSearchResult",
273
+ "TicketMetricsSearchData",
274
+ "TicketMetricsSearchResult",
275
+ "TicketsSearchData",
276
+ "TicketsSearchResult",
277
+ "UsersSearchData",
278
+ "UsersSearchResult",
189
279
  "TicketsListParams",
190
280
  "TicketsGetParams",
191
281
  "UsersListParams",
@@ -226,4 +316,42 @@ __all__ = [
226
316
  "ArticleAttachmentsListParams",
227
317
  "ArticleAttachmentsGetParams",
228
318
  "ArticleAttachmentsDownloadParams",
319
+ "AirbyteSearchParams",
320
+ "AirbyteSortOrder",
321
+ "BrandsSearchFilter",
322
+ "BrandsSearchQuery",
323
+ "BrandsCondition",
324
+ "GroupsSearchFilter",
325
+ "GroupsSearchQuery",
326
+ "GroupsCondition",
327
+ "OrganizationsSearchFilter",
328
+ "OrganizationsSearchQuery",
329
+ "OrganizationsCondition",
330
+ "SatisfactionRatingsSearchFilter",
331
+ "SatisfactionRatingsSearchQuery",
332
+ "SatisfactionRatingsCondition",
333
+ "TagsSearchFilter",
334
+ "TagsSearchQuery",
335
+ "TagsCondition",
336
+ "TicketAuditsSearchFilter",
337
+ "TicketAuditsSearchQuery",
338
+ "TicketAuditsCondition",
339
+ "TicketCommentsSearchFilter",
340
+ "TicketCommentsSearchQuery",
341
+ "TicketCommentsCondition",
342
+ "TicketFieldsSearchFilter",
343
+ "TicketFieldsSearchQuery",
344
+ "TicketFieldsCondition",
345
+ "TicketFormsSearchFilter",
346
+ "TicketFormsSearchQuery",
347
+ "TicketFormsCondition",
348
+ "TicketMetricsSearchFilter",
349
+ "TicketMetricsSearchQuery",
350
+ "TicketMetricsCondition",
351
+ "TicketsSearchFilter",
352
+ "TicketsSearchQuery",
353
+ "TicketsCondition",
354
+ "UsersSearchFilter",
355
+ "UsersSearchQuery",
356
+ "UsersCondition",
229
357
  ]
@@ -152,6 +152,11 @@ class OAuth2AuthConfig(TypedDict, total=False):
152
152
  Note: Any config key can be used as a template variable in refresh_url.
153
153
  Common patterns: subdomain (Zendesk), shop (Shopify), region (AWS-style APIs).
154
154
 
155
+ additional_headers: Extra headers to inject alongside the OAuth2 Bearer token.
156
+ Useful for APIs that require both OAuth and an API key/client ID header.
157
+ Values support Jinja2 {{ variable }} template syntax to reference secrets.
158
+ Example: {"Amazon-Advertising-API-ClientId": "{{ client_id }}"}
159
+
155
160
  Examples:
156
161
  GitHub (simple):
157
162
  {"header": "Authorization", "prefix": "Bearer"}
@@ -169,6 +174,14 @@ class OAuth2AuthConfig(TypedDict, total=False):
169
174
  "auth_style": "basic",
170
175
  "body_format": "json"
171
176
  }
177
+
178
+ Amazon Ads (OAuth + additional client ID header):
179
+ {
180
+ "refresh_url": "https://api.amazon.com/auth/o2/token",
181
+ "additional_headers": {
182
+ "Amazon-Advertising-API-ClientId": "{{ client_id }}"
183
+ }
184
+ }
172
185
  """
173
186
 
174
187
  header: str
@@ -177,6 +190,7 @@ class OAuth2AuthConfig(TypedDict, total=False):
177
190
  auth_style: AuthStyle
178
191
  body_format: BodyFormat
179
192
  subdomain: str
193
+ additional_headers: dict[str, str]
180
194
 
181
195
 
182
196
  class OAuth2AuthSecrets(TypedDict):
@@ -552,9 +566,10 @@ class OAuth2AuthStrategy(AuthStrategy):
552
566
  config: OAuth2AuthConfig,
553
567
  secrets: OAuth2AuthSecrets,
554
568
  ) -> dict[str, str]:
555
- """Inject OAuth2 access token into headers.
569
+ """Inject OAuth2 access token and additional headers.
556
570
 
557
- Creates a copy of the headers dict with the OAuth2 token added.
571
+ Creates a copy of the headers dict with the OAuth2 token added,
572
+ plus any additional headers configured via additional_headers.
558
573
  The original headers dict is not modified.
559
574
 
560
575
  Args:
@@ -563,7 +578,7 @@ class OAuth2AuthStrategy(AuthStrategy):
563
578
  secrets: OAuth2 credentials including access_token
564
579
 
565
580
  Returns:
566
- New headers dict with OAuth2 token authentication injected
581
+ New headers dict with OAuth2 token and additional headers injected
567
582
 
568
583
  Raises:
569
584
  AuthenticationError: If access_token is missing
@@ -589,8 +604,44 @@ class OAuth2AuthStrategy(AuthStrategy):
589
604
  # Extract secret value (handle both SecretStr and plain str)
590
605
  token_value = extract_secret_value(access_token)
591
606
 
592
- # Inject into headers
607
+ # Inject OAuth2 Bearer token
593
608
  headers[header_name] = f"{prefix} {token_value}"
609
+
610
+ # Inject additional headers if configured
611
+ additional_headers = config.get("additional_headers")
612
+ if additional_headers:
613
+ headers = self._inject_additional_headers(headers, additional_headers, secrets)
614
+
615
+ return headers
616
+
617
+ def _inject_additional_headers(
618
+ self,
619
+ headers: dict[str, str],
620
+ additional_headers: dict[str, str],
621
+ secrets: dict[str, Any],
622
+ ) -> dict[str, str]:
623
+ """Inject additional headers with Jinja2 template variable substitution.
624
+
625
+ Processes additional_headers config, substituting {{ variable }} patterns
626
+ with values from secrets using Jinja2 templating.
627
+
628
+ Args:
629
+ headers: Headers dict to add to (modified in place)
630
+ additional_headers: Header name -> value template mapping
631
+ secrets: Secrets dict for variable substitution
632
+
633
+ Returns:
634
+ Headers dict with additional headers added
635
+ """
636
+ # Build template context with extracted secret values
637
+ template_context = {key: extract_secret_value(value) for key, value in secrets.items()}
638
+
639
+ for header_name, value_template in additional_headers.items():
640
+ # Use Jinja2 templating for variable substitution
641
+ template = Template(value_template)
642
+ header_value = template.render(**template_context)
643
+ headers[header_name] = header_value
644
+
594
645
  return headers
595
646
 
596
647
  def validate_credentials(self, secrets: OAuth2AuthSecrets) -> None: # type: ignore[override]
@@ -188,7 +188,7 @@ def parse_openapi_spec(raw_config: dict) -> OpenAPIConnector:
188
188
 
189
189
  def _extract_request_body_config(
190
190
  request_body: RequestBody | None, spec_dict: dict[str, Any]
191
- ) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None]:
191
+ ) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None, dict[str, Any]]:
192
192
  """Extract request body configuration (GraphQL or standard).
193
193
 
194
194
  Args:
@@ -196,17 +196,19 @@ def _extract_request_body_config(
196
196
  spec_dict: Full OpenAPI spec dict for $ref resolution
197
197
 
198
198
  Returns:
199
- Tuple of (body_fields, request_schema, graphql_body)
199
+ Tuple of (body_fields, request_schema, graphql_body, request_body_defaults)
200
200
  - body_fields: List of field names for standard JSON/form bodies
201
201
  - request_schema: Resolved request schema dict (for standard bodies)
202
202
  - graphql_body: GraphQL body configuration dict (for GraphQL bodies)
203
+ - request_body_defaults: Default values for request body fields
203
204
  """
204
205
  body_fields: list[str] = []
205
206
  request_schema: dict[str, Any] | None = None
206
207
  graphql_body: dict[str, Any] | None = None
208
+ request_body_defaults: dict[str, Any] = {}
207
209
 
208
210
  if not request_body:
209
- return body_fields, request_schema, graphql_body
211
+ return body_fields, request_schema, graphql_body, request_body_defaults
210
212
 
211
213
  # Check for GraphQL extension and extract GraphQL body configuration
212
214
  if request_body.x_airbyte_body_type:
@@ -216,7 +218,7 @@ def _extract_request_body_config(
216
218
  if isinstance(body_type_config, GraphQLBodyConfig):
217
219
  # Convert Pydantic model to dict, excluding None values
218
220
  graphql_body = body_type_config.model_dump(exclude_none=True, by_alias=False)
219
- return body_fields, request_schema, graphql_body
221
+ return body_fields, request_schema, graphql_body, request_body_defaults
220
222
 
221
223
  # Parse standard request body
222
224
  for content_type_key, media_type in request_body.content.items():
@@ -226,11 +228,15 @@ def _extract_request_body_config(
226
228
  # Resolve all $refs in the schema using jsonref
227
229
  request_schema = resolve_schema_refs(schema, spec_dict)
228
230
 
229
- # Extract body field names from resolved schema
231
+ # Extract body field names and defaults from resolved schema
230
232
  if isinstance(request_schema, dict) and "properties" in request_schema:
231
233
  body_fields = list(request_schema["properties"].keys())
234
+ # Extract default values for each property
235
+ for field_name, field_schema in request_schema["properties"].items():
236
+ if isinstance(field_schema, dict) and "default" in field_schema:
237
+ request_body_defaults[field_name] = field_schema["default"]
232
238
 
233
- return body_fields, request_schema, graphql_body
239
+ return body_fields, request_schema, graphql_body, request_body_defaults
234
240
 
235
241
 
236
242
  def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel:
@@ -315,6 +321,8 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
315
321
  query_params: list[str] = []
316
322
  query_params_schema: dict[str, dict[str, Any]] = {}
317
323
  deep_object_params: list[str] = []
324
+ header_params: list[str] = []
325
+ header_params_schema: dict[str, dict[str, Any]] = {}
318
326
 
319
327
  if operation.parameters:
320
328
  for param in operation.parameters:
@@ -336,9 +344,12 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
336
344
  # Check if this is a deepObject style parameter
337
345
  if hasattr(param, "style") and param.style == "deepObject":
338
346
  deep_object_params.append(param.name)
347
+ elif param.in_ == "header":
348
+ header_params.append(param.name)
349
+ header_params_schema[param.name] = schema_info
339
350
 
340
- # Extract body fields from request schema
341
- body_fields, request_schema, graphql_body = _extract_request_body_config(operation.request_body, spec_dict)
351
+ # Extract body fields and defaults from request schema
352
+ body_fields, request_schema, graphql_body, request_body_defaults = _extract_request_body_config(operation.request_body, spec_dict)
342
353
 
343
354
  # Extract response schema
344
355
  response_schema = None
@@ -372,6 +383,9 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
372
383
  deep_object_params=deep_object_params,
373
384
  path_params=path_params,
374
385
  path_params_schema=path_params_schema,
386
+ header_params=header_params,
387
+ header_params_schema=header_params_schema,
388
+ request_body_defaults=request_body_defaults,
375
389
  content_type=content_type,
376
390
  request_schema=request_schema,
377
391
  response_schema=response_schema,
@@ -535,6 +549,13 @@ def _parse_oauth2_config(scheme: Any) -> dict[str, str]:
535
549
  if x_token_extract:
536
550
  config["token_extract"] = x_token_extract
537
551
 
552
+ # Extract additional_headers from x-airbyte-auth-config extension
553
+ x_auth_config = getattr(scheme, "x_airbyte_auth_config", None)
554
+ if x_auth_config:
555
+ additional_headers = getattr(x_auth_config, "additional_headers", None)
556
+ if additional_headers:
557
+ config["additional_headers"] = additional_headers
558
+
538
559
  return config
539
560
 
540
561
 
@@ -64,6 +64,7 @@ class _OperationContext:
64
64
  self.build_path = executor._build_path
65
65
  self.extract_query_params = executor._extract_query_params
66
66
  self.extract_body = executor._extract_body
67
+ self.extract_header_params = executor._extract_header_params
67
68
  self.build_request_body = executor._build_request_body
68
69
  self.determine_request_format = executor._determine_request_format
69
70
  self.validate_required_body_fields = executor._validate_required_body_fields
@@ -693,6 +694,40 @@ class LocalExecutor:
693
694
  """
694
695
  return {key: value for key, value in params.items() if key in allowed_fields and value is not None}
695
696
 
697
+ def _extract_header_params(self, endpoint: EndpointDefinition, params: dict[str, Any], body: dict[str, Any] | None = None) -> dict[str, str]:
698
+ """Extract header parameters from params and schema defaults.
699
+
700
+ Also adds Content-Type header when there's a request body (unless already specified
701
+ as a header parameter in the OpenAPI spec).
702
+
703
+ Args:
704
+ endpoint: Endpoint definition with header_params and header_params_schema
705
+ params: All parameters
706
+ body: Request body (if any) - used to determine if Content-Type should be added
707
+
708
+ Returns:
709
+ Dictionary of header name -> value
710
+ """
711
+ headers: dict[str, str] = {}
712
+
713
+ for header_name in endpoint.header_params:
714
+ # Check if value is provided in params
715
+ if header_name in params and params[header_name] is not None:
716
+ headers[header_name] = str(params[header_name])
717
+ # Otherwise, use default from schema if available
718
+ elif header_name in endpoint.header_params_schema:
719
+ default_value = endpoint.header_params_schema[header_name].get("default")
720
+ if default_value is not None:
721
+ headers[header_name] = str(default_value)
722
+
723
+ # Add Content-Type header when there's a request body, but only if not already
724
+ # specified as a header parameter (which allows custom content types like
725
+ # application/vnd.spCampaign.v3+json)
726
+ if body is not None and endpoint.content_type and "Content-Type" not in headers:
727
+ headers["Content-Type"] = endpoint.content_type.value
728
+
729
+ return headers
730
+
696
731
  def _serialize_deep_object_params(self, params: dict[str, Any], deep_object_param_names: list[str]) -> dict[str, Any]:
697
732
  """Serialize deepObject parameters to bracket notation format.
698
733
 
@@ -848,7 +883,15 @@ class LocalExecutor:
848
883
  param_defaults = {name: schema.get("default") for name, schema in endpoint.query_params_schema.items() if "default" in schema}
849
884
  return self._build_graphql_body(endpoint.graphql_body, params, param_defaults)
850
885
  elif endpoint.body_fields:
851
- return self._extract_body(endpoint.body_fields, params)
886
+ # Start with defaults from request body schema
887
+ body = dict(endpoint.request_body_defaults)
888
+ # Override with user-provided params (filtering out None values)
889
+ user_body = self._extract_body(endpoint.body_fields, params)
890
+ body.update(user_body)
891
+ return body if body else None
892
+ elif endpoint.request_body_defaults:
893
+ # If no body_fields but we have defaults, return the defaults
894
+ return dict(endpoint.request_body_defaults)
852
895
  return None
853
896
 
854
897
  def _flatten_form_data(self, data: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
@@ -987,7 +1030,9 @@ class LocalExecutor:
987
1030
 
988
1031
  # Substitute variables from params
989
1032
  if "variables" in graphql_config and graphql_config["variables"]:
990
- body["variables"] = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
1033
+ variables = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
1034
+ # Filter out None values (optional fields not provided) - matches REST _extract_body() behavior
1035
+ body["variables"] = {k: v for k, v in variables.items() if v is not None}
991
1036
 
992
1037
  # Add operation name if specified
993
1038
  if "operationName" in graphql_config:
@@ -1222,15 +1267,22 @@ class LocalExecutor:
1222
1267
  def _extract_metadata(
1223
1268
  self,
1224
1269
  response_data: dict[str, Any],
1270
+ response_headers: dict[str, str],
1225
1271
  endpoint: EndpointDefinition,
1226
1272
  ) -> dict[str, Any] | None:
1227
1273
  """Extract metadata from response using meta extractor.
1228
1274
 
1229
- Each field in meta_extractor dict is independently extracted using JSONPath.
1275
+ Each field in meta_extractor dict is independently extracted using JSONPath
1276
+ for body extraction, or special prefixes for header extraction:
1277
+ - @link.{rel}: Extract URL from RFC 5988 Link header by rel type
1278
+ - @header.{name}: Extract raw header value by header name
1279
+ - Otherwise: JSONPath expression for body extraction
1280
+
1230
1281
  Missing or invalid paths result in None for that field (no crash).
1231
1282
 
1232
1283
  Args:
1233
1284
  response_data: Full API response (before record extraction)
1285
+ response_headers: HTTP response headers
1234
1286
  endpoint: Endpoint with optional meta extractor configuration
1235
1287
 
1236
1288
  Returns:
@@ -1241,11 +1293,15 @@ class LocalExecutor:
1241
1293
  Example:
1242
1294
  meta_extractor = {
1243
1295
  "pagination": "$.records",
1244
- "request_id": "$.requestId"
1296
+ "request_id": "$.requestId",
1297
+ "next_page_url": "@link.next",
1298
+ "rate_limit": "@header.X-RateLimit-Remaining"
1245
1299
  }
1246
1300
  Returns: {
1247
1301
  "pagination": {"cursor": "abc", "total": 100},
1248
- "request_id": "xyz123"
1302
+ "request_id": "xyz123",
1303
+ "next_page_url": "https://api.example.com/data?cursor=abc",
1304
+ "rate_limit": "99"
1249
1305
  }
1250
1306
  """
1251
1307
  # Check if endpoint has meta extractor
@@ -1255,26 +1311,96 @@ class LocalExecutor:
1255
1311
  extracted_meta: dict[str, Any] = {}
1256
1312
 
1257
1313
  # Extract each field independently
1258
- for field_name, jsonpath_expr_str in endpoint.meta_extractor.items():
1314
+ for field_name, extractor_expr in endpoint.meta_extractor.items():
1259
1315
  try:
1260
- # Parse and apply JSONPath expression
1261
- jsonpath_expr = parse_jsonpath(jsonpath_expr_str)
1262
- matches = [match.value for match in jsonpath_expr.find(response_data)]
1263
-
1264
- if matches:
1265
- # Return first match (most common case)
1266
- extracted_meta[field_name] = matches[0]
1316
+ if extractor_expr.startswith("@link."):
1317
+ # RFC 5988 Link header extraction
1318
+ rel = extractor_expr[6:]
1319
+ extracted_meta[field_name] = self._extract_link_url(response_headers, rel)
1320
+ elif extractor_expr.startswith("@header."):
1321
+ # Raw header value extraction (case-insensitive lookup)
1322
+ header_name = extractor_expr[8:]
1323
+ extracted_meta[field_name] = self._get_header_value(response_headers, header_name)
1267
1324
  else:
1268
- # Path not found - set to None
1269
- extracted_meta[field_name] = None
1325
+ # JSONPath body extraction
1326
+ jsonpath_expr = parse_jsonpath(extractor_expr)
1327
+ matches = [match.value for match in jsonpath_expr.find(response_data)]
1328
+
1329
+ if matches:
1330
+ # Return first match (most common case)
1331
+ extracted_meta[field_name] = matches[0]
1332
+ else:
1333
+ # Path not found - set to None
1334
+ extracted_meta[field_name] = None
1270
1335
 
1271
1336
  except Exception as e:
1272
1337
  # Log error but continue with other fields
1273
- logging.warning(f"Failed to apply meta extractor for field '{field_name}' with path '{jsonpath_expr_str}': {e}. Setting to None.")
1338
+ logging.warning(f"Failed to apply meta extractor for field '{field_name}' with expression '{extractor_expr}': {e}. Setting to None.")
1274
1339
  extracted_meta[field_name] = None
1275
1340
 
1276
1341
  return extracted_meta
1277
1342
 
1343
+ @staticmethod
1344
+ def _extract_link_url(headers: dict[str, str], rel: str) -> str | None:
1345
+ """Extract URL from RFC 5988 Link header by rel type.
1346
+
1347
+ Parses Link header format: <url>; param1="value1"; rel="next"; param2="value2"
1348
+
1349
+ Supports:
1350
+ - Multiple parameters per link in any order
1351
+ - Both quoted and unquoted rel values
1352
+ - Multiple links separated by commas
1353
+
1354
+ Args:
1355
+ headers: Response headers dict
1356
+ rel: The rel type to extract (e.g., "next", "prev", "first", "last")
1357
+
1358
+ Returns:
1359
+ The URL for the specified rel type, or None if not found
1360
+ """
1361
+ link_header = headers.get("Link") or headers.get("link", "")
1362
+ if not link_header:
1363
+ return None
1364
+
1365
+ for link_segment in re.split(r",(?=\s*<)", link_header):
1366
+ link_segment = link_segment.strip()
1367
+
1368
+ url_match = re.match(r"<([^>]+)>", link_segment)
1369
+ if not url_match:
1370
+ continue
1371
+
1372
+ url = url_match.group(1)
1373
+ params_str = link_segment[url_match.end() :]
1374
+
1375
+ rel_match = re.search(r';\s*rel="?([^";,]+)"?', params_str, re.IGNORECASE)
1376
+ if rel_match and rel_match.group(1).strip() == rel:
1377
+ return url
1378
+
1379
+ return None
1380
+
1381
+ @staticmethod
1382
+ def _get_header_value(headers: dict[str, str], header_name: str) -> str | None:
1383
+ """Get header value with case-insensitive lookup.
1384
+
1385
+ Args:
1386
+ headers: Response headers dict
1387
+ header_name: Header name to look up
1388
+
1389
+ Returns:
1390
+ Header value or None if not found
1391
+ """
1392
+ # Try exact match first
1393
+ if header_name in headers:
1394
+ return headers[header_name]
1395
+
1396
+ # Case-insensitive lookup
1397
+ header_name_lower = header_name.lower()
1398
+ for key, value in headers.items():
1399
+ if key.lower() == header_name_lower:
1400
+ return value
1401
+
1402
+ return None
1403
+
1278
1404
  def _validate_required_body_fields(self, endpoint: Any, params: dict[str, Any], action: Action, entity: str) -> None:
1279
1405
  """Validate that required body fields are present for CREATE/UPDATE operations.
1280
1406
 
@@ -1401,20 +1527,24 @@ class _StandardOperationHandler:
1401
1527
  # Determine request format (json/data parameters)
1402
1528
  request_kwargs = self.ctx.determine_request_format(endpoint, body)
1403
1529
 
1530
+ # Extract header parameters from OpenAPI operation (pass body to add Content-Type)
1531
+ header_params = self.ctx.extract_header_params(endpoint, params, body)
1532
+
1404
1533
  # Execute async HTTP request
1405
- response = await self.ctx.http_client.request(
1534
+ response_data, response_headers = await self.ctx.http_client.request(
1406
1535
  method=endpoint.method,
1407
1536
  path=path,
1408
1537
  params=query_params if query_params else None,
1409
1538
  json=request_kwargs.get("json"),
1410
1539
  data=request_kwargs.get("data"),
1540
+ headers=header_params if header_params else None,
1411
1541
  )
1412
1542
 
1413
1543
  # Extract metadata from original response (before record extraction)
1414
- metadata = self.ctx.executor._extract_metadata(response, endpoint)
1544
+ metadata = self.ctx.executor._extract_metadata(response_data, response_headers, endpoint)
1415
1545
 
1416
1546
  # Extract records if extractor configured
1417
- response = self.ctx.extract_records(response, endpoint)
1547
+ response = self.ctx.extract_records(response_data, endpoint)
1418
1548
 
1419
1549
  # Assume success with 200 status code if no exception raised
1420
1550
  status_code = 200
@@ -1540,7 +1670,7 @@ class _DownloadOperationHandler:
1540
1670
  request_format = self.ctx.determine_request_format(operation, request_body)
1541
1671
  self.ctx.validate_required_body_fields(operation, params, action, entity)
1542
1672
 
1543
- metadata_response = await self.ctx.http_client.request(
1673
+ metadata_response, _ = await self.ctx.http_client.request(
1544
1674
  method=operation.method,
1545
1675
  path=path,
1546
1676
  params=query_params,
@@ -1555,7 +1685,7 @@ class _DownloadOperationHandler:
1555
1685
  )
1556
1686
 
1557
1687
  # Step 3: Stream file from extracted URL
1558
- file_response = await self.ctx.http_client.request(
1688
+ file_response, _ = await self.ctx.http_client.request(
1559
1689
  method="GET",
1560
1690
  path=file_url,
1561
1691
  headers=headers,
@@ -1563,7 +1693,7 @@ class _DownloadOperationHandler:
1563
1693
  )
1564
1694
  else:
1565
1695
  # One-step direct download: stream file directly from endpoint
1566
- file_response = await self.ctx.http_client.request(
1696
+ file_response, _ = await self.ctx.http_client.request(
1567
1697
  method=operation.method,
1568
1698
  path=path,
1569
1699
  params=query_params,