airbyte-agent-shopify 0.1.1__py3-none-any.whl → 0.1.13__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.
@@ -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]
@@ -62,6 +62,53 @@ class TokenExtractValidationError(ConnectorModelLoaderError):
62
62
  pass
63
63
 
64
64
 
65
+ # Expected auth_mapping keys for each auth type.
66
+ # These are the auth parameters that each security scheme expects, NOT the user's credential field names.
67
+ EXPECTED_AUTH_MAPPING_KEYS: dict[AuthType, set[str]] = {
68
+ AuthType.BEARER: {"token"},
69
+ AuthType.BASIC: {"username", "password"},
70
+ AuthType.API_KEY: {"api_key"},
71
+ AuthType.OAUTH2: {"access_token", "refresh_token", "client_id", "client_secret"},
72
+ }
73
+
74
+
75
+ def _validate_auth_mapping_keys(
76
+ auth_type: AuthType,
77
+ auth_config: AirbyteAuthConfig | None,
78
+ scheme_name: str = "default",
79
+ ) -> None:
80
+ """Validate that auth_mapping keys match expected parameters for the auth type.
81
+
82
+ The auth_mapping keys must be the parameters expected by the security scheme
83
+ (e.g., "token" for bearer), not the user's credential field names.
84
+
85
+ Args:
86
+ auth_type: The authentication type
87
+ auth_config: The x-airbyte-auth-config containing auth_mapping
88
+ scheme_name: Name of the security scheme for error messages
89
+
90
+ Raises:
91
+ InvalidOpenAPIError: If auth_mapping keys don't match expected parameters
92
+ """
93
+ if auth_config is None or auth_config.auth_mapping is None:
94
+ return # No explicit auth_mapping, will use defaults
95
+
96
+ expected_keys = EXPECTED_AUTH_MAPPING_KEYS.get(auth_type)
97
+ if expected_keys is None:
98
+ return # Unknown auth type, skip validation
99
+
100
+ actual_keys = set(auth_config.auth_mapping.keys())
101
+ invalid_keys = actual_keys - expected_keys
102
+
103
+ if invalid_keys:
104
+ raise InvalidOpenAPIError(
105
+ f"Invalid auth_mapping keys for {auth_type.value} auth in scheme '{scheme_name}': {invalid_keys}. "
106
+ f"Expected keys for {auth_type.value}: {sorted(expected_keys)}. "
107
+ f"Note: auth_mapping keys must be the auth parameters (e.g., 'token' for bearer), "
108
+ f'not your credential field names. Use template syntax to map: token: "${{your_field}}"'
109
+ )
110
+
111
+
65
112
  def extract_path_params(path: str) -> list[str]:
66
113
  """Extract parameter names from path template.
67
114
 
@@ -145,6 +192,87 @@ def _deproxy_schema(obj: Any) -> Any:
145
192
  return obj
146
193
 
147
194
 
195
+ def _type_includes(type_value: Any, target: str) -> bool:
196
+ if isinstance(type_value, list):
197
+ return target in type_value
198
+ return type_value == target
199
+
200
+
201
+ def _flatten_cache_properties(properties: dict[str, Any], prefix: str) -> list[str]:
202
+ entries: list[str] = []
203
+ for prop_name, prop in properties.items():
204
+ path = f"{prefix}{prop_name}" if prefix else prop_name
205
+ entries.append(path)
206
+
207
+ prop_type = getattr(prop, "type", None) if not isinstance(prop, dict) else prop.get("type")
208
+ prop_properties = getattr(prop, "properties", None) if not isinstance(prop, dict) else prop.get("properties")
209
+
210
+ if _type_includes(prop_type, "array"):
211
+ array_path = f"{path}[]"
212
+ entries.append(array_path)
213
+ if isinstance(prop_properties, dict):
214
+ entries.extend(_flatten_cache_properties(prop_properties, prefix=f"{array_path}."))
215
+ elif isinstance(prop_properties, dict):
216
+ entries.extend(_flatten_cache_properties(prop_properties, prefix=f"{path}."))
217
+
218
+ return entries
219
+
220
+
221
+ def _flatten_cache_field_paths(field: Any) -> list[str]:
222
+ field_name = getattr(field, "name", None) if not isinstance(field, dict) else field.get("name")
223
+ if not isinstance(field_name, str) or not field_name:
224
+ return []
225
+
226
+ field_type = getattr(field, "type", None) if not isinstance(field, dict) else field.get("type")
227
+ field_properties = getattr(field, "properties", None) if not isinstance(field, dict) else field.get("properties")
228
+
229
+ entries = [field_name]
230
+ if _type_includes(field_type, "array"):
231
+ array_path = f"{field_name}[]"
232
+ entries.append(array_path)
233
+ if isinstance(field_properties, dict):
234
+ entries.extend(_flatten_cache_properties(field_properties, prefix=f"{array_path}."))
235
+ elif isinstance(field_properties, dict):
236
+ entries.extend(_flatten_cache_properties(field_properties, prefix=f"{field_name}."))
237
+
238
+ return entries
239
+
240
+
241
+ def _dedupe_strings(values: list[str]) -> list[str]:
242
+ seen: set[str] = set()
243
+ ordered: list[str] = []
244
+ for value in values:
245
+ if value not in seen:
246
+ seen.add(value)
247
+ ordered.append(value)
248
+ return ordered
249
+
250
+
251
+ def _extract_search_field_paths(spec: OpenAPIConnector) -> dict[str, list[str]]:
252
+ cache_config = getattr(spec.info, "x_airbyte_cache", None)
253
+ entities = getattr(cache_config, "entities", None)
254
+ if not isinstance(entities, list):
255
+ return {}
256
+
257
+ search_fields: dict[str, list[str]] = {}
258
+ for entity in entities:
259
+ entity_name = getattr(entity, "entity", None) if not isinstance(entity, dict) else entity.get("entity")
260
+ if not isinstance(entity_name, str) or not entity_name:
261
+ continue
262
+
263
+ fields = getattr(entity, "fields", None) if not isinstance(entity, dict) else entity.get("fields")
264
+ if not isinstance(fields, list):
265
+ continue
266
+
267
+ field_paths: list[str] = []
268
+ for field in fields:
269
+ field_paths.extend(_flatten_cache_field_paths(field))
270
+
271
+ search_fields[entity_name] = _dedupe_strings(field_paths)
272
+
273
+ return search_fields
274
+
275
+
148
276
  def parse_openapi_spec(raw_config: dict) -> OpenAPIConnector:
149
277
  """Parse OpenAPI specification from YAML.
150
278
 
@@ -188,7 +316,7 @@ def parse_openapi_spec(raw_config: dict) -> OpenAPIConnector:
188
316
 
189
317
  def _extract_request_body_config(
190
318
  request_body: RequestBody | None, spec_dict: dict[str, Any]
191
- ) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None]:
319
+ ) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None, dict[str, Any]]:
192
320
  """Extract request body configuration (GraphQL or standard).
193
321
 
194
322
  Args:
@@ -196,17 +324,19 @@ def _extract_request_body_config(
196
324
  spec_dict: Full OpenAPI spec dict for $ref resolution
197
325
 
198
326
  Returns:
199
- Tuple of (body_fields, request_schema, graphql_body)
327
+ Tuple of (body_fields, request_schema, graphql_body, request_body_defaults)
200
328
  - body_fields: List of field names for standard JSON/form bodies
201
329
  - request_schema: Resolved request schema dict (for standard bodies)
202
330
  - graphql_body: GraphQL body configuration dict (for GraphQL bodies)
331
+ - request_body_defaults: Default values for request body fields
203
332
  """
204
333
  body_fields: list[str] = []
205
334
  request_schema: dict[str, Any] | None = None
206
335
  graphql_body: dict[str, Any] | None = None
336
+ request_body_defaults: dict[str, Any] = {}
207
337
 
208
338
  if not request_body:
209
- return body_fields, request_schema, graphql_body
339
+ return body_fields, request_schema, graphql_body, request_body_defaults
210
340
 
211
341
  # Check for GraphQL extension and extract GraphQL body configuration
212
342
  if request_body.x_airbyte_body_type:
@@ -216,7 +346,7 @@ def _extract_request_body_config(
216
346
  if isinstance(body_type_config, GraphQLBodyConfig):
217
347
  # Convert Pydantic model to dict, excluding None values
218
348
  graphql_body = body_type_config.model_dump(exclude_none=True, by_alias=False)
219
- return body_fields, request_schema, graphql_body
349
+ return body_fields, request_schema, graphql_body, request_body_defaults
220
350
 
221
351
  # Parse standard request body
222
352
  for content_type_key, media_type in request_body.content.items():
@@ -226,11 +356,15 @@ def _extract_request_body_config(
226
356
  # Resolve all $refs in the schema using jsonref
227
357
  request_schema = resolve_schema_refs(schema, spec_dict)
228
358
 
229
- # Extract body field names from resolved schema
359
+ # Extract body field names and defaults from resolved schema
230
360
  if isinstance(request_schema, dict) and "properties" in request_schema:
231
361
  body_fields = list(request_schema["properties"].keys())
362
+ # Extract default values for each property
363
+ for field_name, field_schema in request_schema["properties"].items():
364
+ if isinstance(field_schema, dict) and "default" in field_schema:
365
+ request_body_defaults[field_name] = field_schema["default"]
232
366
 
233
- return body_fields, request_schema, graphql_body
367
+ return body_fields, request_schema, graphql_body, request_body_defaults
234
368
 
235
369
 
236
370
  def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel:
@@ -315,6 +449,8 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
315
449
  query_params: list[str] = []
316
450
  query_params_schema: dict[str, dict[str, Any]] = {}
317
451
  deep_object_params: list[str] = []
452
+ header_params: list[str] = []
453
+ header_params_schema: dict[str, dict[str, Any]] = {}
318
454
 
319
455
  if operation.parameters:
320
456
  for param in operation.parameters:
@@ -336,9 +472,12 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
336
472
  # Check if this is a deepObject style parameter
337
473
  if hasattr(param, "style") and param.style == "deepObject":
338
474
  deep_object_params.append(param.name)
475
+ elif param.in_ == "header":
476
+ header_params.append(param.name)
477
+ header_params_schema[param.name] = schema_info
339
478
 
340
- # Extract body fields from request schema
341
- body_fields, request_schema, graphql_body = _extract_request_body_config(operation.request_body, spec_dict)
479
+ # Extract body fields and defaults from request schema
480
+ body_fields, request_schema, graphql_body, request_body_defaults = _extract_request_body_config(operation.request_body, spec_dict)
342
481
 
343
482
  # Extract response schema
344
483
  response_schema = None
@@ -372,6 +511,9 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
372
511
  deep_object_params=deep_object_params,
373
512
  path_params=path_params,
374
513
  path_params_schema=path_params_schema,
514
+ header_params=header_params,
515
+ header_params_schema=header_params_schema,
516
+ request_body_defaults=request_body_defaults,
375
517
  content_type=content_type,
376
518
  request_schema=request_schema,
377
519
  response_schema=response_schema,
@@ -420,6 +562,8 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
420
562
  if not connector_id:
421
563
  raise InvalidOpenAPIError("Missing required x-airbyte-connector-id field")
422
564
 
565
+ search_field_paths = _extract_search_field_paths(spec)
566
+
423
567
  # Create ConnectorModel
424
568
  model = ConnectorModel(
425
569
  id=connector_id,
@@ -430,6 +574,7 @@ def convert_openapi_to_connector_model(spec: OpenAPIConnector) -> ConnectorModel
430
574
  entities=entities,
431
575
  openapi_spec=spec,
432
576
  retry_config=retry_config,
577
+ search_field_paths=search_field_paths,
433
578
  )
434
579
 
435
580
  return model
@@ -535,6 +680,13 @@ def _parse_oauth2_config(scheme: Any) -> dict[str, str]:
535
680
  if x_token_extract:
536
681
  config["token_extract"] = x_token_extract
537
682
 
683
+ # Extract additional_headers from x-airbyte-auth-config extension
684
+ x_auth_config = getattr(scheme, "x_airbyte_auth_config", None)
685
+ if x_auth_config:
686
+ additional_headers = getattr(x_auth_config, "additional_headers", None)
687
+ if additional_headers:
688
+ config["additional_headers"] = additional_headers
689
+
538
690
  return config
539
691
 
540
692
 
@@ -606,7 +758,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
606
758
  description="Authentication bearer token",
607
759
  format=None,
608
760
  pattern=None,
609
- airbyte_secret=False,
610
761
  default=None,
611
762
  )
612
763
  },
@@ -626,7 +777,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
626
777
  description="Authentication username",
627
778
  format=None,
628
779
  pattern=None,
629
- airbyte_secret=False,
630
780
  default=None,
631
781
  ),
632
782
  "password": AuthConfigFieldSpec(
@@ -635,7 +785,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
635
785
  description="Authentication password",
636
786
  format=None,
637
787
  pattern=None,
638
- airbyte_secret=False,
639
788
  default=None,
640
789
  ),
641
790
  },
@@ -655,7 +804,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
655
804
  description="API authentication key",
656
805
  format=None,
657
806
  pattern=None,
658
- airbyte_secret=False,
659
807
  default=None,
660
808
  )
661
809
  },
@@ -680,7 +828,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
680
828
  description="OAuth2 access token",
681
829
  format=None,
682
830
  pattern=None,
683
- airbyte_secret=False,
684
831
  default=None,
685
832
  ),
686
833
  "refresh_token": AuthConfigFieldSpec(
@@ -689,7 +836,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
689
836
  description="OAuth2 refresh token (optional)",
690
837
  format=None,
691
838
  pattern=None,
692
- airbyte_secret=False,
693
839
  default=None,
694
840
  ),
695
841
  "client_id": AuthConfigFieldSpec(
@@ -698,7 +844,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
698
844
  description="OAuth2 client ID (optional)",
699
845
  format=None,
700
846
  pattern=None,
701
- airbyte_secret=False,
702
847
  default=None,
703
848
  ),
704
849
  "client_secret": AuthConfigFieldSpec(
@@ -707,7 +852,6 @@ def _generate_default_auth_config(auth_type: AuthType) -> AirbyteAuthConfig:
707
852
  description="OAuth2 client secret (optional)",
708
853
  format=None,
709
854
  pattern=None,
710
- airbyte_secret=False,
711
855
  default=None,
712
856
  ),
713
857
  },
@@ -819,6 +963,9 @@ def _parse_single_security_scheme(scheme: Any) -> AuthConfig:
819
963
  oauth2_config = _parse_oauth2_config(scheme)
820
964
  # Use explicit x-airbyte-auth-config if present, otherwise generate default
821
965
  auth_config_obj = scheme.x_airbyte_auth_config or _generate_default_auth_config(AuthType.OAUTH2)
966
+ # Validate auth_mapping keys if explicitly provided
967
+ if scheme.x_airbyte_auth_config:
968
+ _validate_auth_mapping_keys(AuthType.OAUTH2, scheme.x_airbyte_auth_config)
822
969
  return AuthConfig(
823
970
  type=AuthType.OAUTH2,
824
971
  config=oauth2_config,
@@ -829,6 +976,10 @@ def _parse_single_security_scheme(scheme: Any) -> AuthConfig:
829
976
  # Use explicit x-airbyte-auth-config if present, otherwise generate default
830
977
  auth_config_obj = scheme.x_airbyte_auth_config or _generate_default_auth_config(auth_type)
831
978
 
979
+ # Validate auth_mapping keys if explicitly provided
980
+ if scheme.x_airbyte_auth_config:
981
+ _validate_auth_mapping_keys(auth_type, scheme.x_airbyte_auth_config)
982
+
832
983
  return AuthConfig(
833
984
  type=auth_type,
834
985
  config=auth_config,
@@ -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]:
@@ -989,7 +1032,9 @@ class LocalExecutor:
989
1032
  if "variables" in graphql_config and graphql_config["variables"]:
990
1033
  variables = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
991
1034
  # Filter out None values (optional fields not provided) - matches REST _extract_body() behavior
992
- body["variables"] = {k: v for k, v in variables.items() if v is not None}
1035
+ # But preserve None for variables explicitly marked as nullable (e.g., to unassign a user)
1036
+ nullable_vars = set(graphql_config.get("x-airbyte-nullable-variables") or [])
1037
+ body["variables"] = {k: v for k, v in variables.items() if v is not None or k in nullable_vars}
993
1038
 
994
1039
  # Add operation name if specified
995
1040
  if "operationName" in graphql_config:
@@ -1484,6 +1529,9 @@ class _StandardOperationHandler:
1484
1529
  # Determine request format (json/data parameters)
1485
1530
  request_kwargs = self.ctx.determine_request_format(endpoint, body)
1486
1531
 
1532
+ # Extract header parameters from OpenAPI operation (pass body to add Content-Type)
1533
+ header_params = self.ctx.extract_header_params(endpoint, params, body)
1534
+
1487
1535
  # Execute async HTTP request
1488
1536
  response_data, response_headers = await self.ctx.http_client.request(
1489
1537
  method=endpoint.method,
@@ -1491,6 +1539,7 @@ class _StandardOperationHandler:
1491
1539
  params=query_params if query_params else None,
1492
1540
  json=request_kwargs.get("json"),
1493
1541
  data=request_kwargs.get("data"),
1542
+ headers=header_params if header_params else None,
1494
1543
  )
1495
1544
 
1496
1545
  # Extract metadata from original response (before record extraction)