diff --git a/recap/converters/json_schema.py b/recap/converters/json_schema.py index d413e15..ffdac9b 100644 --- a/recap/converters/json_schema.py +++ b/recap/converters/json_schema.py @@ -50,10 +50,14 @@ def to_recap( def _parse( self, - json_schema: dict, + json_schema: dict | str, alias_strategy: AliasStrategy, ) -> RecapType: extra_attrs = {} + # Check if json_schema is just a string representing a basic type, and convert + # to a dict with a "type" property if so + if isinstance(json_schema, str): + json_schema = {"type": json_schema} if "description" in json_schema: extra_attrs["doc"] = json_schema["description"] if "default" in json_schema: @@ -66,12 +70,23 @@ def _parse( extra_attrs["alias"] = alias_strategy(resource_id) match json_schema: + # Special handling for "type" defined as a list of strings like + # {"type": ["string", "boolean"]} + case {"type": list(type_list)}: + types = [self._parse(s, alias_strategy) for s in type_list] + return UnionType(types, **extra_attrs) case {"type": "object", "properties": properties}: fields = [] for name, prop in properties.items(): field = self._parse(prop, alias_strategy) + # If not explicitly required, make optional by ensuring the field is + # nullable, and has a default if name not in json_schema.get("required", []): - field = field.make_nullable() + if not field.is_nullable(): + field = field.make_nullable() + if "default" not in field.extra_attrs: + field.extra_attrs["default"] = None + field.extra_attrs["name"] = name fields.append(field) return StructType(fields, **extra_attrs) diff --git a/recap/types.py b/recap/types.py index fdbe679..ac0875f 100644 --- a/recap/types.py +++ b/recap/types.py @@ -55,6 +55,14 @@ def make_nullable(self) -> UnionType: type_copy = RecapTypeClass(**attrs, **extra_attrs) return UnionType([NullType(), type_copy], **union_attrs) + def is_nullable(self) -> bool: + """ + Returns True if the type is nullable. + :return: True if the type is nullable. + """ + + return isinstance(self, UnionType) and NullType() in self.types + def validate(self) -> None: # Default to valid type pass diff --git a/tests/unit/converters/test_json_schema.py b/tests/unit/converters/test_json_schema.py index b61401d..095e32b 100644 --- a/tests/unit/converters/test_json_schema.py +++ b/tests/unit/converters/test_json_schema.py @@ -65,6 +65,72 @@ def test_all_basic_types(): ] +def test_nullable_types(): + """Tests nullable types (["null", "string"]), with and without default values. Also tests that nullable properties aren't + made double nullable if they're not required.""" + json_schema = """ + { + "type": "object", + "properties": { + "required_nullable_no_default": {"type": ["null", "string"]}, + "required_nullable_with_null_default": {"type": ["null", "string"], "default": null}, + "required_nullable_with_default": {"type": ["null", "string"], "default": "default_value"}, + "nullable_no_default": {"type": ["null", "string"]}, + "nullable_with_null_default": {"type": ["null", "string"], "default": null}, + "nullable_with_default": {"type": ["null", "string"], "default": "default_value"} + }, + "required": ["required_nullable_no_default", "required_nullable_with_null_default", "required_nullable_with_default"] + } + """ + Draft202012Validator.check_schema(loads(json_schema)) + struct_type = JSONSchemaConverter().to_recap(json_schema) + assert isinstance(struct_type, StructType) + assert struct_type.fields == [ + UnionType([NullType(), StringType()], name="required_nullable_no_default"), + UnionType( + [NullType(), StringType()], + name="required_nullable_with_null_default", + default=None, + ), + UnionType( + [NullType(), StringType()], + name="required_nullable_with_default", + default="default_value", + ), + UnionType([NullType(), StringType()], name="nullable_no_default", default=None), + UnionType( + [NullType(), StringType()], + name="nullable_with_null_default", + default=None, + ), + UnionType( + [NullType(), StringType()], + name="nullable_with_default", + default="default_value", + ), + ] + + +def test_union_types(): + json_schema = """ + { + "type": "object", + "properties": { + "union": {"type": ["null", "string", "boolean", "number"]} + }, + "required": ["union"] + } + """ + Draft202012Validator.check_schema(loads(json_schema)) + struct_type = JSONSchemaConverter().to_recap(json_schema) + assert isinstance(struct_type, StructType) + assert struct_type.fields == [ + UnionType( + [NullType(), StringType(), BoolType(), FloatType(bits=64)], name="union" + ), + ] + + def test_nested_objects(): json_schema = """ {