Skip to content

Commit 6e3809c

Browse files
author
Joe Reuter
committed
Revert "Revert "Extend SATs to capture UI limitations (#21451)" (#21896)"
This reverts commit 74b5dbf.
1 parent 74b5dbf commit 6e3809c

File tree

5 files changed

+373
-65
lines changed

5 files changed

+373
-65
lines changed

airbyte-integrations/bases/source-acceptance-test/CHANGELOG.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
# Changelog
22

3-
## 0.4.0
4-
Revert 0.3.0
5-
63
## 0.3.0
7-
(Broken) Add various stricter checks for specs (see PR for details). [#21451](https://github.com/airbytehq/airbyte/pull/21451)
4+
Add various stricter checks for specs (see PR for details). [#21451](https://github.com/airbytehq/airbyte/pull/21451)
85

96
## 0.2.26
107
Check `future_state` only for incremental streams. [#21248](https://github.com/airbytehq/airbyte/pull/21248)

airbyte-integrations/bases/source-acceptance-test/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ COPY pytest.ini setup.py ./
3333
COPY source_acceptance_test ./source_acceptance_test
3434
RUN pip install .
3535

36-
LABEL io.airbyte.version=0.4.0
36+
LABEL io.airbyte.version=0.3.0
3737
LABEL io.airbyte.name=airbyte/source-acceptance-test
3838

3939
ENTRYPOINT ["python", "-m", "pytest", "-p", "source_acceptance_test.plugin", "-r", "fEsx"]

airbyte-integrations/bases/source-acceptance-test/source_acceptance_test/tests/test_core.py

Lines changed: 144 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,20 @@ def secret_property_names_fixture():
7676
)
7777

7878

79+
DATE_PATTERN = "^[0-9]{2}-[0-9]{2}-[0-9]{4}$"
80+
DATETIME_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2})?$"
81+
82+
7983
@pytest.mark.default_timeout(10)
8084
class TestSpec(BaseTest):
8185

8286
spec_cache: ConnectorSpecification = None
8387
previous_spec_cache: ConnectorSpecification = None
8488

89+
@pytest.fixture(name="connection_specification", scope="class")
90+
def connection_specification_fixture(self, connector_spec_dict: dict) -> dict:
91+
return connector_spec_dict["connectionSpecification"]
92+
8593
@pytest.fixture(name="skip_backward_compatibility_tests")
8694
def skip_backward_compatibility_tests_fixture(
8795
self,
@@ -219,22 +227,15 @@ def _property_can_store_secret(prop: dict) -> bool:
219227
Some fields can not hold a secret by design, others can.
220228
Null type as well as boolean can not hold a secret value.
221229
A string, a number or an integer type can always store secrets.
222-
Objects and arrays can hold a secret in case they are generic,
223-
meaning their inner structure is not described in details with properties/items.
230+
Secret objects and arrays can not be rendered correctly in the UI:
224231
A field with a constant value can not hold a secret as well.
225232
"""
226233
unsecure_types = {"string", "integer", "number"}
227234
type_ = prop["type"]
228-
is_property_generic_object = type_ == "object" and not any(
229-
[prop.get("properties", {}), prop.get("anyOf", []), prop.get("oneOf", []), prop.get("allOf", [])]
230-
)
231-
is_property_generic_array = type_ == "array" and not any([prop.get("items", []), prop.get("prefixItems", [])])
232235
is_property_constant_value = bool(prop.get("const"))
233236
can_store_secret = any(
234237
[
235238
isinstance(type_, str) and type_ in unsecure_types,
236-
is_property_generic_object,
237-
is_property_generic_array,
238239
isinstance(type_, list) and (set(type_) & unsecure_types),
239240
]
240241
)
@@ -252,7 +253,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
252253
secrets_exposed = []
253254
non_secrets_hidden = []
254255
spec_properties = connector_spec_dict["connectionSpecification"]["properties"]
255-
for type_path, value in dpath.util.search(spec_properties, "**/type", yielded=True):
256+
for type_path, type_value in dpath.util.search(spec_properties, "**/type", yielded=True):
256257
_, is_property_name_secret = self._is_spec_property_name_secret(type_path, secret_property_names)
257258
if not is_property_name_secret:
258259
continue
@@ -268,7 +269,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
268269

269270
if non_secrets_hidden:
270271
properties = "\n".join(non_secrets_hidden)
271-
detailed_logger.warning(
272+
pytest.fail(
272273
f"""Some properties are marked with `airbyte_secret` although they probably should not be.
273274
Please double check them. If they're okay, please fix this test.
274275
{properties}"""
@@ -280,6 +281,139 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
280281
{properties}"""
281282
)
282283

284+
def _fail_on_errors(self, errors: List[str]):
285+
if len(errors) > 0:
286+
pytest.fail("\n".join(errors))
287+
288+
def test_property_type_is_not_array(self, connector_specification: dict):
289+
"""
290+
Each field has one or multiple types, but the UI only supports a single type and optionally "null" as a second type.
291+
"""
292+
errors = []
293+
for type_path, type_value in dpath.util.search(connector_specification, "**/properties/*/type", yielded=True):
294+
if isinstance(type_value, List):
295+
number_of_types = len(type_value)
296+
if number_of_types != 2 and number_of_types != 1:
297+
errors.append(
298+
f"{type_path} is not either a simple type or an array of a simple type plus null: {type_value} (for example: type: [string, null])"
299+
)
300+
if number_of_types == 2 and type_value[1] != "null":
301+
errors.append(
302+
f"Second type of {type_path} is not null: {type_value}. Type can either be a simple type or an array of a simple type plus null (for example: type: [string, null])"
303+
)
304+
self._fail_on_errors(errors)
305+
306+
def test_object_not_empty(self, connector_specification: dict):
307+
"""
308+
Each object field needs to have at least one property as the UI won't be able to show them otherwise.
309+
If the whole spec is empty, it's allowed to have a single empty object at the top level
310+
"""
311+
schema_helper = JsonSchemaHelper(connector_specification)
312+
errors = []
313+
for type_path, type_value in dpath.util.search(connector_specification, "**/type", yielded=True):
314+
if type_path == "type":
315+
# allow empty root object
316+
continue
317+
if type_value == "object":
318+
property = schema_helper.get_parent(type_path)
319+
if "oneOf" not in property and ("properties" not in property or len(property["properties"]) == 0):
320+
errors.append(
321+
f"{type_path} is an empty object which will not be represented correctly in the UI. Either remove or add specific properties"
322+
)
323+
self._fail_on_errors(errors)
324+
325+
def test_array_type(self, connector_specification: dict):
326+
"""
327+
Each array has one or multiple types for its items, but the UI only supports a single type which can either be object, string or an enum
328+
"""
329+
schema_helper = JsonSchemaHelper(connector_specification)
330+
errors = []
331+
for type_path, type_type in dpath.util.search(connector_specification, "**/type", yielded=True):
332+
property_definition = schema_helper.get_parent(type_path)
333+
if type_type != "array":
334+
# unrelated "items", not an array definition
335+
continue
336+
items_value = property_definition.get("items", None)
337+
if items_value is None:
338+
continue
339+
elif isinstance(items_value, List):
340+
errors.append(f"{type_path} is not just a single item type: {items_value}")
341+
elif items_value.get("type") not in ["object", "string", "number", "integer"] and "enum" not in items_value:
342+
errors.append(f"Items of {type_path} has to be either object or string or define an enum")
343+
self._fail_on_errors(errors)
344+
345+
def test_forbidden_complex_types(self, connector_specification: dict):
346+
"""
347+
not, anyOf, patternProperties, prefixItems, allOf, if, then, else, dependentSchemas and dependentRequired are not allowed
348+
"""
349+
forbidden_keys = [
350+
"not",
351+
"anyOf",
352+
"patternProperties",
353+
"prefixItems",
354+
"allOf",
355+
"if",
356+
"then",
357+
"else",
358+
"dependentSchemas",
359+
"dependentRequired",
360+
]
361+
found_keys = set()
362+
for forbidden_key in forbidden_keys:
363+
for path, value in dpath.util.search(connector_specification, f"**/{forbidden_key}", yielded=True):
364+
found_keys.add(path)
365+
366+
for forbidden_key in forbidden_keys:
367+
# remove forbidden keys if they are used as properties directly
368+
for path, _value in dpath.util.search(connector_specification, f"**/properties/{forbidden_key}", yielded=True):
369+
found_keys.remove(path)
370+
371+
if len(found_keys) > 0:
372+
key_list = ", ".join(found_keys)
373+
pytest.fail(f"Found the following disallowed JSON schema features: {key_list}")
374+
375+
def test_date_pattern(self, connector_specification: dict, detailed_logger):
376+
"""
377+
Properties with format date or date-time should always have a pattern defined how the date/date-time should be formatted
378+
that corresponds with the format the datepicker component is creating.
379+
"""
380+
schema_helper = JsonSchemaHelper(connector_specification)
381+
for format_path, format in dpath.util.search(connector_specification, "**/format", yielded=True):
382+
if not isinstance(format, str):
383+
# format is not a format definition here but a property named format
384+
continue
385+
property_definition = schema_helper.get_parent(format_path)
386+
pattern = property_definition.get("pattern")
387+
if format == "date" and not pattern == DATE_PATTERN:
388+
detailed_logger.warning(
389+
f"{format_path} is defining a date format without the corresponding pattern. Consider setting the pattern to {DATE_PATTERN} to make it easier for users to edit this field in the UI."
390+
)
391+
if format == "date-time" and not pattern == DATETIME_PATTERN:
392+
detailed_logger.warning(
393+
f"{format_path} is defining a date-time format without the corresponding pattern Consider setting the pattern to {DATETIME_PATTERN} to make it easier for users to edit this field in the UI."
394+
)
395+
396+
def test_date_format(self, connector_specification: dict, detailed_logger):
397+
"""
398+
Properties with a pattern that looks like a date should have their format set to date or date-time.
399+
"""
400+
schema_helper = JsonSchemaHelper(connector_specification)
401+
for pattern_path, pattern in dpath.util.search(connector_specification, "**/pattern", yielded=True):
402+
if not isinstance(pattern, str):
403+
# pattern is not a pattern definition here but a property named pattern
404+
continue
405+
if pattern == DATE_PATTERN or pattern == DATETIME_PATTERN:
406+
property_definition = schema_helper.get_parent(pattern_path)
407+
format = property_definition.get("format")
408+
if not format == "date" and pattern == DATE_PATTERN:
409+
detailed_logger.warning(
410+
f"{pattern_path} is defining a pattern that looks like a date without setting the format to `date`. Consider specifying the format to make it easier for users to edit this field in the UI."
411+
)
412+
if not format == "date-time" and pattern == DATETIME_PATTERN:
413+
detailed_logger.warning(
414+
f"{pattern_path} is defining a pattern that looks like a date-time without setting the format to `date-time`. Consider specifying the format to make it easier for users to edit this field in the UI."
415+
)
416+
283417
def test_defined_refs_exist_in_json_spec_file(self, connector_spec_dict: dict):
284418
"""Checking for the presence of unresolved `$ref`s values within each json spec file"""
285419
check_result = list(find_all_values_for_key_in_schema(connector_spec_dict, "$ref"))

airbyte-integrations/bases/source-acceptance-test/source_acceptance_test/utils/json_schema_helper.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from functools import reduce
77
from typing import Any, Dict, List, Mapping, Optional, Set, Text, Union
88

9+
import dpath.util
910
import pendulum
1011
from jsonref import JsonRef
1112

@@ -121,6 +122,16 @@ def get_node(self, path: List[str]) -> Any:
121122
node = node[segment]
122123
return node
123124

125+
def get_parent(self, path: str) -> Any:
126+
"""
127+
Returns the parent dict of a given path within the `obj` dict
128+
"""
129+
absolute_path = f"/{path}"
130+
parent_path, _ = absolute_path.rsplit(sep="/", maxsplit=1)
131+
if parent_path == "":
132+
return self._schema
133+
return dpath.util.get(self._schema, parent_path)
134+
124135
def find_nodes(self, keys: List[str]) -> List[List[str]]:
125136
"""Find all paths that lead to nodes with the specified keys.
126137

0 commit comments

Comments
 (0)