Skip to content

Commit

Permalink
Handle "type" being an array of strings in JSON schema converter (#423)
Browse files Browse the repository at this point in the history
Updates to handle the case where a `type` in JSON schema is an array of
strings, like `{"type": ["null", "string", "boolean", "number"]}`. From the
[JSON schema docs](https://json-schema.org/understanding-json-schema/reference/type#type-specific-keywords)

> The type keyword may either be a string or an array:
>
> If it's a string, it is the name of one of the basic types above.
> If it is an array, it must be an array of strings, where each string
> is the name of one of the basic types, and each element is unique.

I added tests to make sure nullable/optional types are converted
correctly, and that properties of an object that are both not required &
a union with null (`{"type": ["null", "string"]}`) are not made "double
nullable".

Closes #412
  • Loading branch information
adrianisk authored Feb 12, 2024
1 parent 7924dfc commit 22d0e73
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 2 deletions.
19 changes: 17 additions & 2 deletions recap/converters/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions recap/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ def make_nullable(self) -> UnionType:
type_copy.types.insert(0, NullType())
return type_copy

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
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/converters/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
{
Expand Down

0 comments on commit 22d0e73

Please sign in to comment.