Skip to content

Commit 1cb2611

Browse files
fix: strip null optional config fields before validation and include field path in error messages
Config validation now strips null-valued non-required fields before running jsonschema validation. This prevents spurious 'None is not of type string' errors when the platform passes null for unset optional fields. Additionally, validation error messages now include the JSON path to the failing field, making config errors actionable for users. Co-Authored-By: gl_anatolii.yatsuk <gl_anatolii.yatsuk@airbyte.io>
1 parent 7da322d commit 1cb2611

2 files changed

Lines changed: 74 additions & 7 deletions

File tree

airbyte_cdk/sources/utils/schema_helpers.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,22 +185,38 @@ def _resolve_schema_references(self, raw_schema: dict[str, Any]) -> dict[str, An
185185
raise ValueError(f"Expected resolved to be a dict. Got {resolved}")
186186

187187

188+
def _strip_nulls_from_optional_fields(config: Mapping[str, Any], spec_schema: Mapping[str, Any]) -> dict[str, Any]:
189+
"""Remove keys with None values that are not required by the schema.
190+
191+
The platform may store optional config fields as explicit JSON null rather
192+
than omitting the key. JSON Schema treats *absent* keys as valid for
193+
non-required properties but rejects null unless the type includes "null".
194+
Stripping these avoids spurious validation failures.
195+
"""
196+
required_fields = set(spec_schema.get("required", []))
197+
return {k: v for k, v in config.items() if v is not None or k in required_fields}
198+
199+
188200
def check_config_against_spec_or_exit(
189201
config: Mapping[str, Any], spec: ConnectorSpecification
190202
) -> None:
191-
"""
192-
Check config object against spec. In case of spec is invalid, throws
193-
an exception with validation error description.
203+
"""Check config object against spec and raise on validation failure.
194204
195-
:param config - config loaded from file specified over command line
196-
:param spec - spec object generated by connector
205+
Strips null-valued optional fields before validation so that connectors
206+
are not broken by platform-level null injection for unset fields.
197207
"""
198208
spec_schema = spec.connectionSpecification
209+
sanitized_config = _strip_nulls_from_optional_fields(config, spec_schema)
199210
try:
200-
validate(instance=config, schema=spec_schema)
211+
validate(instance=sanitized_config, schema=spec_schema)
201212
except ValidationError as validation_error:
213+
field_path = ".".join(str(p) for p in validation_error.absolute_path)
214+
if field_path:
215+
message = f"Config validation error: '{field_path}' - {validation_error.message}"
216+
else:
217+
message = f"Config validation error: {validation_error.message}"
202218
raise AirbyteTracedException(
203-
message="Config validation error: " + validation_error.message,
219+
message=message,
204220
internal_message=validation_error.message,
205221
failure_type=FailureType.config_error,
206222
) from None # required to prevent logging config secrets from the ValidationError's stacktrace

unit_tests/sources/utils/test_schema_helpers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,57 @@ def test_shared_schemas_resolves_nested():
197197
assert jsonref.JsonRef.replace_refs(actual_schema)
198198

199199

200+
@pytest.mark.parametrize(
201+
"config,spec_schema",
202+
[
203+
pytest.param(
204+
{"api_token": "valid", "optional_field": None},
205+
{"type": "object", "required": ["api_token"], "properties": {"api_token": {"type": "string"}, "optional_field": {"type": "string"}}},
206+
id="null_optional_field_is_stripped",
207+
),
208+
pytest.param(
209+
{"api_token": "valid"},
210+
{"type": "object", "required": ["api_token"], "properties": {"api_token": {"type": "string"}, "optional_field": {"type": "string"}}},
211+
id="absent_optional_field_passes",
212+
),
213+
pytest.param(
214+
{"api_token": "valid", "start_date": None, "end_date": None, "app_id": None},
215+
{"type": "object", "required": ["api_token"], "properties": {"api_token": {"type": "string"}, "start_date": {"type": "string"}, "end_date": {"type": "string"}, "app_id": {"type": "string"}}},
216+
id="multiple_null_optional_fields_stripped",
217+
),
218+
],
219+
)
220+
def test_check_config_strips_null_optional_fields(config, spec_schema):
221+
spec = ConnectorSpecificationSerializer.load({"connectionSpecification": spec_schema})
222+
check_config_against_spec_or_exit(config, spec)
223+
224+
225+
@pytest.mark.parametrize(
226+
"config,spec_schema,expected_substring",
227+
[
228+
pytest.param(
229+
{"api_token": None, "optional_field": "present"},
230+
{"type": "object", "required": ["api_token"], "properties": {"api_token": {"type": "string"}, "optional_field": {"type": "string"}}},
231+
"api_token",
232+
id="null_required_field_reports_field_name",
233+
),
234+
pytest.param(
235+
{"credentials": {"token": 123}},
236+
{"type": "object", "required": ["credentials"], "properties": {"credentials": {"type": "object", "required": ["token"], "properties": {"token": {"type": "string"}}}}},
237+
"credentials.token",
238+
id="nested_error_includes_full_path",
239+
),
240+
],
241+
)
242+
def test_check_config_error_message_includes_field_path(config, spec_schema, expected_substring):
243+
spec = ConnectorSpecificationSerializer.load({"connectionSpecification": spec_schema})
244+
with pytest_raises(AirbyteTracedException) as exc_info:
245+
check_config_against_spec_or_exit(config, spec)
246+
assert exc_info.value.failure_type == FailureType.config_error
247+
assert expected_substring in exc_info.value.message
248+
assert "Config validation error:" in exc_info.value.message
249+
250+
200251
@pytest.mark.parametrize(
201252
"limit, record_count, expected",
202253
[

0 commit comments

Comments
 (0)