Skip to content

Commit 8499e24

Browse files
authored
feat: check for request_option mapping conflicts in individual components (#328)
1 parent 10c1085 commit 8499e24

File tree

5 files changed

+197
-9
lines changed

5 files changed

+197
-9
lines changed

airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from airbyte_cdk.sources.message import MessageRepository
2323
from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState
24+
from airbyte_cdk.utils.mapping_helpers import _validate_component_request_option_paths
2425

2526

2627
@dataclass
@@ -122,6 +123,10 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None:
122123
if not self.cursor_datetime_formats:
123124
self.cursor_datetime_formats = [self.datetime_format]
124125

126+
_validate_component_request_option_paths(
127+
self.config, self.start_time_option, self.end_time_option
128+
)
129+
125130
def get_stream_state(self) -> StreamState:
126131
return {self.cursor_field.eval(self.config): self._cursor} if self._cursor else {} # type: ignore # cursor_field is converted to an InterpolatedString in __post_init__
127132

airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py

+10
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
)
2424
from airbyte_cdk.sources.declarative.requesters.request_path import RequestPath
2525
from airbyte_cdk.sources.types import Config, Record, StreamSlice, StreamState
26+
from airbyte_cdk.utils.mapping_helpers import (
27+
_validate_component_request_option_paths,
28+
)
2629

2730

2831
@dataclass
@@ -113,6 +116,13 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None:
113116
if isinstance(self.url_base, str):
114117
self.url_base = InterpolatedString(string=self.url_base, parameters=parameters)
115118

119+
if self.page_token_option and not isinstance(self.page_token_option, RequestPath):
120+
_validate_component_request_option_paths(
121+
self.config,
122+
self.page_size_option,
123+
self.page_token_option,
124+
)
125+
116126
def get_initial_token(self) -> Optional[Any]:
117127
"""
118128
Return the page token that should be used for the first request of a stream

airbyte_cdk/utils/mapping_helpers.py

+43-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
import copy
77
from typing import Any, Dict, List, Mapping, Optional, Union
88

9+
from airbyte_cdk.sources.declarative.requesters.request_option import (
10+
RequestOption,
11+
RequestOptionType,
12+
)
13+
from airbyte_cdk.sources.types import Config
14+
915

1016
def _merge_mappings(
1117
target: Dict[str, Any],
@@ -33,13 +39,17 @@ def _merge_mappings(
3339
if isinstance(target_value, dict) and isinstance(source_value, dict):
3440
# Only body_json supports nested_structures
3541
if not allow_same_value_merge:
36-
raise ValueError(f"Duplicate keys found: {'.'.join(current_path)}")
42+
raise ValueError(
43+
f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique."
44+
)
3745
# If both are dictionaries, recursively merge them
3846
_merge_mappings(target_value, source_value, current_path, allow_same_value_merge)
3947

4048
elif not allow_same_value_merge or target_value != source_value:
4149
# If same key has different values, that's a conflict
42-
raise ValueError(f"Duplicate keys found: {'.'.join(current_path)}")
50+
raise ValueError(
51+
f"Request body collision, duplicate keys detected at key path: {'.'.join(current_path)}. Please ensure that all keys in the request are unique."
52+
)
4353
else:
4454
# No conflict, just copy the value (using deepcopy for nested structures)
4555
target[key] = copy.deepcopy(source_value)
@@ -102,3 +112,34 @@ def combine_mappings(
102112
_merge_mappings(result, mapping, allow_same_value_merge=allow_same_value_merge)
103113

104114
return result
115+
116+
117+
def _validate_component_request_option_paths(
118+
config: Config, *request_options: Optional[RequestOption]
119+
) -> None:
120+
"""
121+
Validates that a component with multiple request options does not have conflicting paths.
122+
Uses dummy values for validation since actual values might not be available at init time.
123+
"""
124+
grouped_options: Dict[RequestOptionType, List[RequestOption]] = {}
125+
for option in request_options:
126+
if option:
127+
grouped_options.setdefault(option.inject_into, []).append(option)
128+
129+
for inject_type, options in grouped_options.items():
130+
if len(options) <= 1:
131+
continue
132+
133+
option_dicts: List[Optional[Union[Mapping[str, Any], str]]] = []
134+
for i, option in enumerate(options):
135+
option_dict: Dict[str, Any] = {}
136+
# Use indexed dummy values to ensure we catch conflicts
137+
option.inject_into_request(option_dict, f"dummy_value_{i}", config)
138+
option_dicts.append(option_dict)
139+
140+
try:
141+
combine_mappings(
142+
option_dicts, allow_same_value_merge=(inject_type == RequestOptionType.body_json)
143+
)
144+
except ValueError as error:
145+
raise ValueError(error)

unit_tests/sources/declarative/requesters/paginators/test_default_paginator.py

+26
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,29 @@ def test_paginator_with_page_option_no_page_size():
447447
parameters={},
448448
),
449449
)
450+
451+
452+
def test_request_option_mapping_validator():
453+
pagination_strategy = PageIncrement(
454+
config={}, page_size=1, start_from_page=0, parameters={}, inject_on_first_request=True
455+
)
456+
457+
with pytest.raises(ValueError):
458+
(
459+
DefaultPaginator(
460+
page_size_option=RequestOption(
461+
field_path=["variables", "limit"],
462+
inject_into=RequestOptionType.body_json,
463+
parameters={},
464+
),
465+
page_token_option=RequestOption(
466+
field_path=["variables", "limit"],
467+
inject_into=RequestOptionType.body_json,
468+
parameters={},
469+
),
470+
pagination_strategy=pagination_strategy,
471+
config=MagicMock(),
472+
url_base=MagicMock(),
473+
parameters={},
474+
),
475+
)

unit_tests/utils/test_mapping_helpers.py

+113-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import pytest
22

3-
from airbyte_cdk.utils.mapping_helpers import combine_mappings
3+
from airbyte_cdk.utils.mapping_helpers import (
4+
RequestOption,
5+
RequestOptionType,
6+
_validate_component_request_option_paths,
7+
combine_mappings,
8+
)
49

510

611
@pytest.mark.parametrize(
@@ -46,14 +51,14 @@ def test_string_handling(test_name, mappings, expected_result, expected_error):
4651
@pytest.mark.parametrize(
4752
"test_name, mappings, expected_error",
4853
[
49-
("duplicate_keys_same_value", [{"a": 1}, {"a": 1}], "Duplicate keys found"),
50-
("duplicate_keys_different_value", [{"a": 1}, {"a": 2}], "Duplicate keys found"),
54+
("duplicate_keys_same_value", [{"a": 1}, {"a": 1}], "duplicate keys detected"),
55+
("duplicate_keys_different_value", [{"a": 1}, {"a": 2}], "duplicate keys detected"),
5156
(
5257
"nested_structure_not_allowed",
5358
[{"a": {"b": 1}}, {"a": {"c": 2}}],
54-
"Duplicate keys found",
59+
"duplicate keys detected",
5560
),
56-
("any_nesting_not_allowed", [{"a": {"b": 1}}, {"a": {"d": 2}}], "Duplicate keys found"),
61+
("any_nesting_not_allowed", [{"a": {"b": 1}}, {"a": {"d": 2}}], "duplicate keys detected"),
5762
],
5863
)
5964
def test_non_body_json_requests(test_name, mappings, expected_error):
@@ -96,13 +101,13 @@ def test_non_body_json_requests(test_name, mappings, expected_error):
96101
"nested_conflict",
97102
[{"a": {"b": 1}}, {"a": {"b": 2}}],
98103
None,
99-
"Duplicate keys found",
104+
"duplicate keys detected",
100105
),
101106
(
102107
"type_conflict",
103108
[{"a": 1}, {"a": {"b": 2}}],
104109
None,
105-
"Duplicate keys found",
110+
"duplicate keys detected",
106111
),
107112
],
108113
)
@@ -113,3 +118,104 @@ def test_body_json_requests(test_name, mappings, expected_result, expected_error
113118
combine_mappings(mappings, allow_same_value_merge=True)
114119
else:
115120
assert combine_mappings(mappings, allow_same_value_merge=True) == expected_result
121+
122+
123+
@pytest.fixture
124+
def mock_config() -> dict[str, str]:
125+
return {"test": "config"}
126+
127+
128+
@pytest.mark.parametrize(
129+
"test_name, option1, option2, should_raise",
130+
[
131+
(
132+
"different_fields",
133+
RequestOption(
134+
field_name="field1", inject_into=RequestOptionType.body_json, parameters={}
135+
),
136+
RequestOption(
137+
field_name="field2", inject_into=RequestOptionType.body_json, parameters={}
138+
),
139+
False,
140+
),
141+
(
142+
"same_field_name_header",
143+
RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}),
144+
RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}),
145+
True,
146+
),
147+
(
148+
"different_nested_paths",
149+
RequestOption(
150+
field_path=["data", "query1", "limit"],
151+
inject_into=RequestOptionType.body_json,
152+
parameters={},
153+
),
154+
RequestOption(
155+
field_path=["data", "query2", "limit"],
156+
inject_into=RequestOptionType.body_json,
157+
parameters={},
158+
),
159+
False,
160+
),
161+
(
162+
"same_nested_paths",
163+
RequestOption(
164+
field_path=["data", "query", "limit"],
165+
inject_into=RequestOptionType.body_json,
166+
parameters={},
167+
),
168+
RequestOption(
169+
field_path=["data", "query", "limit"],
170+
inject_into=RequestOptionType.body_json,
171+
parameters={},
172+
),
173+
True,
174+
),
175+
(
176+
"different_inject_types",
177+
RequestOption(field_name="field", inject_into=RequestOptionType.header, parameters={}),
178+
RequestOption(
179+
field_name="field", inject_into=RequestOptionType.body_json, parameters={}
180+
),
181+
False,
182+
),
183+
],
184+
)
185+
def test_request_option_validation(test_name, option1, option2, should_raise, mock_config):
186+
"""Test various combinations of request option validation"""
187+
if should_raise:
188+
with pytest.raises(ValueError, match="duplicate keys detected"):
189+
_validate_component_request_option_paths(mock_config, option1, option2)
190+
else:
191+
_validate_component_request_option_paths(mock_config, option1, option2)
192+
193+
194+
@pytest.mark.parametrize(
195+
"test_name, options",
196+
[
197+
(
198+
"none_options",
199+
[
200+
None,
201+
RequestOption(
202+
field_name="field", inject_into=RequestOptionType.header, parameters={}
203+
),
204+
None,
205+
],
206+
),
207+
(
208+
"single_option",
209+
[
210+
RequestOption(
211+
field_name="field", inject_into=RequestOptionType.header, parameters={}
212+
)
213+
],
214+
),
215+
("all_none", [None, None, None]),
216+
("empty_list", []),
217+
],
218+
)
219+
def test_edge_cases(test_name, options, mock_config):
220+
"""Test edge cases like None values and single options"""
221+
_validate_component_request_option_paths(mock_config, *options)

0 commit comments

Comments
 (0)