@@ -76,6 +76,10 @@ 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
@@ -219,22 +223,15 @@ def _property_can_store_secret(prop: dict) -> bool:
219
223
Some fields can not hold a secret by design, others can.
220
224
Null type as well as boolean can not hold a secret value.
221
225
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.
226
+ Secret objects and arrays can not be rendered correctly in the UI:
224
227
A field with a constant value can not hold a secret as well.
225
228
"""
226
229
unsecure_types = {"string" , "integer" , "number" }
227
230
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
231
is_property_constant_value = bool (prop .get ("const" ))
233
232
can_store_secret = any (
234
233
[
235
234
isinstance (type_ , str ) and type_ in unsecure_types ,
236
- is_property_generic_object ,
237
- is_property_generic_array ,
238
235
isinstance (type_ , list ) and (set (type_ ) & unsecure_types ),
239
236
]
240
237
)
@@ -252,7 +249,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
252
249
secrets_exposed = []
253
250
non_secrets_hidden = []
254
251
spec_properties = connector_spec_dict ["connectionSpecification" ]["properties" ]
255
- for type_path , value in dpath .util .search (spec_properties , "**/type" , yielded = True ):
252
+ for type_path , type_value in dpath .util .search (spec_properties , "**/type" , yielded = True ):
256
253
_ , is_property_name_secret = self ._is_spec_property_name_secret (type_path , secret_property_names )
257
254
if not is_property_name_secret :
258
255
continue
@@ -268,7 +265,7 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
268
265
269
266
if non_secrets_hidden :
270
267
properties = "\n " .join (non_secrets_hidden )
271
- detailed_logger . warning (
268
+ pytest . fail (
272
269
f"""Some properties are marked with `airbyte_secret` although they probably should not be.
273
270
Please double check them. If they're okay, please fix this test.
274
271
{ properties } """
@@ -280,6 +277,139 @@ def test_secret_is_properly_marked(self, connector_spec_dict: dict, detailed_log
280
277
{ properties } """
281
278
)
282
279
280
+ def _fail_on_errors (self , errors : List [str ]):
281
+ if len (errors ) > 0 :
282
+ pytest .fail ("\n " .join (errors ))
283
+
284
+ def test_property_type_is_not_array (self , connector_spec : ConnectorSpecification ):
285
+ """
286
+ Each field has one or multiple types, but the UI only supports a single type and optionally "null" as a second type.
287
+ """
288
+ errors = []
289
+ for type_path , type_value in dpath .util .search (connector_spec .connectionSpecification , "**/properties/*/type" , yielded = True ):
290
+ if isinstance (type_value , List ):
291
+ number_of_types = len (type_value )
292
+ if number_of_types != 2 and number_of_types != 1 :
293
+ errors .append (
294
+ 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])"
295
+ )
296
+ if number_of_types == 2 and type_value [1 ] != "null" :
297
+ errors .append (
298
+ 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])"
299
+ )
300
+ self ._fail_on_errors (errors )
301
+
302
+ def test_object_not_empty (self , connector_spec : ConnectorSpecification ):
303
+ """
304
+ Each object field needs to have at least one property as the UI won't be able to show them otherwise.
305
+ If the whole spec is empty, it's allowed to have a single empty object at the top level
306
+ """
307
+ schema_helper = JsonSchemaHelper (connector_spec .connectionSpecification )
308
+ errors = []
309
+ for type_path , type_value in dpath .util .search (connector_spec .connectionSpecification , "**/type" , yielded = True ):
310
+ if type_path == "type" :
311
+ # allow empty root object
312
+ continue
313
+ if type_value == "object" :
314
+ property = schema_helper .get_parent (type_path )
315
+ if "oneOf" not in property and ("properties" not in property or len (property ["properties" ]) == 0 ):
316
+ errors .append (
317
+ f"{ type_path } is an empty object which will not be represented correctly in the UI. Either remove or add specific properties"
318
+ )
319
+ self ._fail_on_errors (errors )
320
+
321
+ def test_array_type (self , connector_spec : ConnectorSpecification ):
322
+ """
323
+ 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
324
+ """
325
+ schema_helper = JsonSchemaHelper (connector_spec .connectionSpecification )
326
+ errors = []
327
+ for type_path , type_type in dpath .util .search (connector_spec .connectionSpecification , "**/type" , yielded = True ):
328
+ property_definition = schema_helper .get_parent (type_path )
329
+ if type_type != "array" :
330
+ # unrelated "items", not an array definition
331
+ continue
332
+ items_value = property_definition .get ("items" , None )
333
+ if items_value is None :
334
+ continue
335
+ elif isinstance (items_value , List ):
336
+ errors .append (f"{ type_path } is not just a single item type: { items_value } " )
337
+ elif items_value .get ("type" ) not in ["object" , "string" , "number" , "integer" ] and "enum" not in items_value :
338
+ errors .append (f"Items of { type_path } has to be either object or string or define an enum" )
339
+ self ._fail_on_errors (errors )
340
+
341
+ def test_forbidden_complex_types (self , connector_spec : ConnectorSpecification ):
342
+ """
343
+ not, anyOf, patternProperties, prefixItems, allOf, if, then, else, dependentSchemas and dependentRequired are not allowed
344
+ """
345
+ forbidden_keys = [
346
+ "not" ,
347
+ "anyOf" ,
348
+ "patternProperties" ,
349
+ "prefixItems" ,
350
+ "allOf" ,
351
+ "if" ,
352
+ "then" ,
353
+ "else" ,
354
+ "dependentSchemas" ,
355
+ "dependentRequired" ,
356
+ ]
357
+ found_keys = set ()
358
+ for forbidden_key in forbidden_keys :
359
+ for path , value in dpath .util .search (connector_spec .connectionSpecification , f"**/{ forbidden_key } " , yielded = True ):
360
+ found_keys .add (path )
361
+
362
+ for forbidden_key in forbidden_keys :
363
+ # remove forbidden keys if they are used as properties directly
364
+ for path , _value in dpath .util .search (connector_spec .connectionSpecification , f"**/properties/{ forbidden_key } " , yielded = True ):
365
+ found_keys .remove (path )
366
+
367
+ if len (found_keys ) > 0 :
368
+ key_list = ", " .join (found_keys )
369
+ pytest .fail (f"Found the following disallowed JSON schema features: { key_list } " )
370
+
371
+ def test_date_pattern (self , connector_spec : ConnectorSpecification , detailed_logger ):
372
+ """
373
+ Properties with format date or date-time should always have a pattern defined how the date/date-time should be formatted
374
+ that corresponds with the format the datepicker component is creating.
375
+ """
376
+ schema_helper = JsonSchemaHelper (connector_spec .connectionSpecification )
377
+ for format_path , format in dpath .util .search (connector_spec .connectionSpecification , "**/format" , yielded = True ):
378
+ if not isinstance (format , str ):
379
+ # format is not a format definition here but a property named format
380
+ continue
381
+ property_definition = schema_helper .get_parent (format_path )
382
+ pattern = property_definition .get ("pattern" )
383
+ if format == "date" and not pattern == DATE_PATTERN :
384
+ detailed_logger .warning (
385
+ 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."
386
+ )
387
+ if format == "date-time" and not pattern == DATETIME_PATTERN :
388
+ detailed_logger .warning (
389
+ 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."
390
+ )
391
+
392
+ def test_date_format (self , connector_spec : ConnectorSpecification , detailed_logger ):
393
+ """
394
+ Properties with a pattern that looks like a date should have their format set to date or date-time.
395
+ """
396
+ schema_helper = JsonSchemaHelper (connector_spec .connectionSpecification )
397
+ for pattern_path , pattern in dpath .util .search (connector_spec .connectionSpecification , "**/pattern" , yielded = True ):
398
+ if not isinstance (pattern , str ):
399
+ # pattern is not a pattern definition here but a property named pattern
400
+ continue
401
+ if pattern == DATE_PATTERN or pattern == DATETIME_PATTERN :
402
+ property_definition = schema_helper .get_parent (pattern_path )
403
+ format = property_definition .get ("format" )
404
+ if not format == "date" and pattern == DATE_PATTERN :
405
+ detailed_logger .warning (
406
+ 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."
407
+ )
408
+ if not format == "date-time" and pattern == DATETIME_PATTERN :
409
+ detailed_logger .warning (
410
+ 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."
411
+ )
412
+
283
413
def test_defined_refs_exist_in_json_spec_file (self , connector_spec_dict : dict ):
284
414
"""Checking for the presence of unresolved `$ref`s values within each json spec file"""
285
415
check_result = list (find_all_values_for_key_in_schema (connector_spec_dict , "$ref" ))
0 commit comments