@@ -76,12 +76,20 @@ def secret_property_names_fixture():
76
76
)
77
77
78
78
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
+
79
83
@pytest .mark .default_timeout (10 )
80
84
class TestSpec (BaseTest ):
81
85
82
86
spec_cache : ConnectorSpecification = None
83
87
previous_spec_cache : ConnectorSpecification = None
84
88
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
+
85
93
@pytest .fixture (name = "skip_backward_compatibility_tests" )
86
94
def skip_backward_compatibility_tests_fixture (
87
95
self ,
@@ -219,22 +227,15 @@ def _property_can_store_secret(prop: dict) -> bool:
219
227
Some fields can not hold a secret by design, others can.
220
228
Null type as well as boolean can not hold a secret value.
221
229
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:
224
231
A field with a constant value can not hold a secret as well.
225
232
"""
226
233
unsecure_types = {"string" , "integer" , "number" }
227
234
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" , [])])
232
235
is_property_constant_value = bool (prop .get ("const" ))
233
236
can_store_secret = any (
234
237
[
235
238
isinstance (type_ , str ) and type_ in unsecure_types ,
236
- is_property_generic_object ,
237
- is_property_generic_array ,
238
239
isinstance (type_ , list ) and (set (type_ ) & unsecure_types ),
239
240
]
240
241
)
@@ -252,7 +253,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
252
253
secrets_exposed = []
253
254
non_secrets_hidden = []
254
255
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 ):
256
257
_ , is_property_name_secret = self ._is_spec_property_name_secret (type_path , secret_property_names )
257
258
if not is_property_name_secret :
258
259
continue
@@ -268,7 +269,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
268
269
269
270
if non_secrets_hidden :
270
271
properties = "\n " .join (non_secrets_hidden )
271
- detailed_logger . warning (
272
+ pytest . fail (
272
273
f"""Some properties are marked with `airbyte_secret` although they probably should not be.
273
274
Please double check them. If they're okay, please fix this test.
274
275
{ properties } """
@@ -280,6 +281,139 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
280
281
{ properties } """
281
282
)
282
283
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
+
283
417
def test_defined_refs_exist_in_json_spec_file (self , connector_spec_dict : dict ):
284
418
"""Checking for the presence of unresolved `$ref`s values within each json spec file"""
285
419
check_result = list (find_all_values_for_key_in_schema (connector_spec_dict , "$ref" ))
0 commit comments