from jambo import SchemaConverter from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException from jambo.types import JSONSchema from pydantic import AnyUrl, BaseModel, ValidationError from typing_extensions import get_args from ipaddress import IPv4Address, IPv6Address from unittest import TestCase from uuid import UUID def is_pydantic_model(cls): return isinstance(cls, type) and issubclass(cls, BaseModel) class TestSchemaConverter(TestCase): def setUp(self): self.converter = SchemaConverter() def tearDown(self): self.converter.clear_ref_cache() def test_invalid_schema(self): schema = { "title": 1, "description": "A person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, } with self.assertRaises(InvalidSchemaException): self.converter.build_with_cache(schema) def test_invalid_schema_type(self): schema = { "title": 1, "description": "A person", "type": 1, "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, } with self.assertRaises(InvalidSchemaException): self.converter.build_with_cache(schema) def test_build_expects_title(self): schema = { "description": "A person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, } with self.assertRaises(InvalidSchemaException): self.converter.build_with_cache(schema) def test_build_expects_object(self): schema = { "title": "Person", "description": "A person", "type": "string", } with self.assertRaises(UnsupportedSchemaException): self.converter.build_with_cache(schema) def test_is_invalid_field(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "id": { "notType": "string", } }, # 'required': ['name', 'age', 'is_active', 'friends', 'address'], } with self.assertRaises(InvalidSchemaException) as context: self.converter.build_with_cache(schema) self.assertTrue("Unknown type" in str(context.exception)) def test_jsonschema_to_pydantic(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, "required": ["name"], } model = self.converter.build_with_cache(schema) self.assertTrue(is_pydantic_model(model)) def test_validation_string(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "name": {"type": "string", "maxLength": 4, "minLength": 1}, "email": { "type": "string", "maxLength": 50, "minLength": 5, "pattern": r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", }, }, "required": ["name"], } model = self.converter.build_with_cache(schema) self.assertEqual(model(name="John", age=30).name, "John") with self.assertRaises(ValidationError): model(name=123, age=30, email="teste@hideyoshi.com") with self.assertRaises(ValidationError): model(name="John Invalid", age=45, email="teste@hideyoshi.com") with self.assertRaises(ValidationError): model(name="", age=45, email="teste@hideyoshi.com") with self.assertRaises(ValidationError): model(name="John", age=45, email="hideyoshi.com") def test_validation_integer(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "age": { "type": "integer", "minimum": 0, "maximum": 120, }, }, "required": ["age"], } model = self.converter.build_with_cache(schema) self.assertEqual(model(age=30).age, 30) with self.assertRaises(ValidationError): model(age=-1) with self.assertRaises(ValidationError): model(age=121) def test_validation_float(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "age": { "type": "number", "minimum": 0, "maximum": 120, }, }, "required": ["age"], } model = self.converter.build_with_cache(schema) self.assertEqual(model(age=30).age, 30.0) with self.assertRaises(ValidationError): model(age=-1.0) with self.assertRaises(ValidationError): model(age=121.0) def test_validation_boolean(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "is_active": {"type": "boolean"}, }, "required": ["is_active"], } model = self.converter.build_with_cache(schema) self.assertEqual(model(is_active=True).is_active, True) self.assertEqual(model(is_active="true").is_active, True) def test_validation_list_with_valid_items(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "friends": { "type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 2, "uniqueItems": True, }, }, "required": ["friends"], } model = self.converter.build_with_cache(schema) self.assertEqual( model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"} ) with self.assertRaises(ValidationError): model(friends=[]) with self.assertRaises(ValidationError): model(friends=["John", "Jane", "Invalid"]) def test_validation_list_with_missing_items(self): model = self.converter.build_with_cache( { "title": "Person", "description": "A person", "type": "object", "properties": { "friends": { "type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 2, }, }, "required": ["friends"], } ) with self.assertRaises(ValidationError): model() def test_validation_object(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "address": { "type": "object", "properties": { "street": {"type": "string"}, "city": {"type": "string"}, }, "required": ["street", "city"], }, }, "required": ["address"], } model = self.converter.build_with_cache(schema) obj = model(address={"street": "123 Main St", "city": "Springfield"}) self.assertEqual(obj.address.street, "123 Main St") self.assertEqual(obj.address.city, "Springfield") with self.assertRaises(ValidationError): model() def test_default_for_string(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "name": { "type": "string", "default": "John", }, }, "required": ["name"], } model = self.converter.build_with_cache(schema) obj = model(name="John") self.assertEqual(obj.name, "John") def test_invalid_default_for_string(self): # Test for default with maxLength schema_max_length = { "title": "Person", "description": "A person", "type": "object", "properties": { "name": { "type": "string", "default": "John", "maxLength": 2, }, }, "required": ["name"], } with self.assertRaises(InvalidSchemaException): self.converter.build_with_cache(schema_max_length) def test_default_for_list(self): schema_list = { "title": "Person", "description": "A person", "type": "object", "properties": { "friends": { "type": "array", "items": {"type": "string"}, "default": ["John", "Jane"], }, }, "required": ["friends"], } model_list = self.converter.build_with_cache(schema_list) self.assertEqual(model_list().friends, ["John", "Jane"]) def test_default_for_list_with_unique_items(self): # Test for default with uniqueItems schema_set = { "title": "Person", "description": "A person", "type": "object", "properties": { "friends": { "type": "array", "items": {"type": "string"}, "default": ["John", "Jane"], "uniqueItems": True, }, }, "required": ["friends"], } model_set = self.converter.build_with_cache(schema_set) self.assertEqual(model_set().friends, {"John", "Jane"}) def test_default_for_object(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "address": { "type": "object", "properties": { "street": {"type": "string"}, "city": {"type": "string"}, }, "default": {"street": "123 Main St", "city": "Springfield"}, }, }, "required": ["address"], } model = self.converter.build_with_cache(schema) obj = model(address={"street": "123 Main St", "city": "Springfield"}) self.assertEqual(obj.address.street, "123 Main St") self.assertEqual(obj.address.city, "Springfield") def test_all_of(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "name": { "allOf": [ {"type": "string", "maxLength": 11}, {"type": "string", "maxLength": 4}, {"type": "string", "minLength": 1}, {"type": "string", "minLength": 2}, ] }, }, } Model = self.converter.build_with_cache(schema) obj = Model( name="J", ) self.assertEqual(obj.name, "J") with self.assertRaises(ValidationError): Model(name="John Invalid") with self.assertRaises(ValidationError): Model(name="") def test_any_of(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { "id": { "anyOf": [ {"type": "string", "maxLength": 11, "minLength": 1}, {"type": "integer", "maximum": 10}, ] }, }, } Model = self.converter.build_with_cache(schema) obj = Model(id=1) self.assertEqual(obj.id, 1) obj = Model(id="12345678901") self.assertEqual(obj.id, "12345678901") with self.assertRaises(ValidationError): Model(id="") with self.assertRaises(ValidationError): Model(id="12345678901234567890") with self.assertRaises(ValidationError): Model(id=11) def test_string_format_email(self): schema = { "title": "EmailTest", "type": "object", "properties": {"email": {"type": "string", "format": "email"}}, } model = self.converter.build_with_cache(schema) self.assertEqual(model(email="test@example.com").email, "test@example.com") with self.assertRaises(ValidationError): model(email="invalid-email") def test_string_format_uri(self): schema = { "title": "UriTest", "type": "object", "properties": {"website": {"type": "string", "format": "uri"}}, } model = self.converter.build_with_cache(schema) self.assertEqual( model(website="https://example.com").website, AnyUrl("https://example.com") ) with self.assertRaises(ValidationError): model(website="invalid-uri") def test_string_format_ipv4(self): schema = { "title": "IPv4Test", "type": "object", "properties": {"ip": {"type": "string", "format": "ipv4"}}, } model = self.converter.build_with_cache(schema) self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1")) with self.assertRaises(ValidationError): model(ip="256.256.256.256") def test_string_format_ipv6(self): schema = { "title": "IPv6Test", "type": "object", "properties": {"ip": {"type": "string", "format": "ipv6"}}, } model = self.converter.build_with_cache(schema) self.assertEqual( model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip, IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), ) with self.assertRaises(ValidationError): model(ip="invalid-ipv6") def test_string_format_uuid(self): schema = { "title": "UUIDTest", "type": "object", "properties": {"id": {"type": "string", "format": "uuid"}}, } model = self.converter.build_with_cache(schema) self.assertEqual( model(id="123e4567-e89b-12d3-a456-426614174000").id, UUID("123e4567-e89b-12d3-a456-426614174000"), ) with self.assertRaises(ValidationError): model(id="invalid-uuid") def test_string_format_hostname(self): schema = { "title": "HostnameTest", "type": "object", "properties": {"hostname": {"type": "string", "format": "hostname"}}, } model = self.converter.build_with_cache(schema) self.assertEqual(model(hostname="example.com").hostname, "example.com") with self.assertRaises(ValidationError): model(hostname="invalid..hostname") def test_string_format_datetime(self): schema = { "title": "DateTimeTest", "type": "object", "properties": {"timestamp": {"type": "string", "format": "date-time"}}, } model = self.converter.build_with_cache(schema) self.assertEqual( model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(), "2024-01-01T12:00:00+00:00", ) with self.assertRaises(ValidationError): model(timestamp="invalid-datetime") def test_string_format_time(self): schema = { "title": "TimeTest", "type": "object", "properties": {"time": {"type": "string", "format": "time"}}, } model = self.converter.build_with_cache(schema) self.assertEqual( model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00" ) with self.assertRaises(ValidationError): model(time="25:00:00") def test_string_format_unsupported(self): schema = { "title": "InvalidFormat", "type": "object", "properties": {"field": {"type": "string", "format": "unsupported"}}, } with self.assertRaises(InvalidSchemaException): self.converter.build_with_cache(schema) def test_ref_with_root_ref(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#", }, }, "required": ["name", "age"], } model = self.converter.build_with_cache(schema) obj = model( name="John", age=30, emergency_contact=model( name="Jane", age=28, ), ) self.assertEqual(obj.name, "John") self.assertEqual(obj.age, 30) self.assertIsInstance(obj.emergency_contact, model) self.assertEqual(obj.emergency_contact.name, "Jane") self.assertEqual(obj.emergency_contact.age, 28) def test_ref_with_def(self): schema = { "title": "person", "$ref": "#/$defs/person", "$defs": { "person": { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#/$defs/person", }, }, } }, } model = self.converter.build_with_cache(schema) obj = model( name="John", age=30, emergency_contact=model( name="Jane", age=28, ), ) self.assertEqual(obj.name, "John") self.assertEqual(obj.age, 30) self.assertIsInstance(obj.emergency_contact, model) self.assertEqual(obj.emergency_contact.name, "Jane") self.assertEqual(obj.emergency_contact.age, 28) def test_ref_with_def_another_model(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "address": {"$ref": "#/$defs/Address"}, }, "required": ["name"], "$defs": { "Address": { "type": "object", "properties": { "street": {"type": "string"}, "city": {"type": "string"}, }, "required": ["street", "city"], } }, } Model = self.converter.build_with_cache(schema) obj = Model( name="John", age=30, address={"street": "123 Main St", "city": "Springfield"}, ) self.assertEqual(obj.name, "John") self.assertEqual(obj.age, 30) self.assertEqual(obj.address.street, "123 Main St") self.assertEqual(obj.address.city, "Springfield") def test_enum_type_parser(self): schema = { "title": "Person", "type": "object", "properties": { "status": { "type": "string", "enum": ["active", "inactive", "pending"], } }, "required": ["status"], } Model = self.converter.build_with_cache(schema) obj = Model(status="active") self.assertEqual(obj.status.value, "active") def test_enum_type_parser_with_default(self): schema = { "title": "Person", "type": "object", "properties": { "status": { "type": "string", "enum": ["active", "inactive", "pending"], "default": "active", } }, "required": ["status"], } Model = self.converter.build_with_cache(schema) obj = Model() self.assertEqual(obj.status.value, "active") def test_const_type_parser(self): schema = { "title": "Country", "type": "object", "properties": { "name": { "const": "United States of America", } }, "required": ["name"], } Model = self.converter.build_with_cache(schema) obj = Model() self.assertEqual(obj.name, "United States of America") with self.assertRaises(ValidationError): obj.name = "Canada" with self.assertRaises(ValidationError): Model(name="Canada") def test_const_type_parser_with_non_hashable_value(self): schema = { "title": "Country", "type": "object", "properties": { "name": { "const": ["Brazil"], } }, "required": ["name"], } Model = self.converter.build_with_cache(schema) obj = Model() self.assertEqual(obj.name, ["Brazil"]) with self.assertRaises(ValidationError): obj.name = ["Argentina"] with self.assertRaises(ValidationError): Model(name=["Argentina"]) def test_null_type_parser(self): schema = { "title": "Test", "type": "object", "properties": { "a_thing": {"type": "null"}, }, } Model = self.converter.build_with_cache(schema) obj = Model() self.assertIsNone(obj.a_thing) obj = Model(a_thing=None) self.assertIsNone(obj.a_thing) with self.assertRaises(ValidationError): Model(a_thing="not none") def test_scoped_ref_schema(self): schema: JSONSchema = { "title": "Example Schema", "type": "object", "properties": { "operating_system": { "oneOf": [ {"$ref": "#/$defs/operating_system"}, { "type": "object", "properties": { "creation": {"$ref": "#/$defs/operating_system"}, "reinstallation": {"$ref": "#/$defs/operating_system"}, }, "required": ["creation", "reinstallation"], }, ] }, }, "$defs": { "operating_system": { "type": "object", "properties": { "name": {"type": "string"}, "version": {"type": "string"}, }, "required": ["name", "version"], } }, } schema_type = self.converter.build_with_cache(schema) # check for me that the types generated by the oneOf in the typing.Annotated have different names operating_system_field = schema_type.model_fields["operating_system"] arg1, arg2 = get_args(operating_system_field.annotation) first_type = get_args(arg1)[0] second_type = get_args(arg2)[0] self.assertNotEqual(first_type.__name__, second_type.__name__) def test_object_invalid_require(self): # https://github.com/HideyoshiNakazone/jambo/issues/60 object_ = self.converter.build_with_cache( { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "TEST", "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "description": "The title of the object", }, "description": { "type": "object", "properties": { "summary": { "type": "string", }, "details": { "type": "string", }, }, }, }, } ) self.assertFalse(object_.model_fields["description"].is_required()) # FAIL def test_instance_level_ref_cache(self): ref_cache = {} schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#", }, }, "required": ["name", "age"], } converter1 = SchemaConverter(ref_cache) model1 = converter1.build_with_cache(schema) converter2 = SchemaConverter(ref_cache) model2 = converter2.build_with_cache(schema) self.assertIs(converter1._ref_cache, converter2._ref_cache) self.assertIs(model1, model2) def test_instance_level_ref_cache_isolation_via_without_cache_param(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#", }, }, "required": ["name", "age"], } model1 = self.converter.build_with_cache(schema, without_cache=True) model2 = self.converter.build_with_cache(schema, without_cache=True) self.assertIsNot(model1, model2) def test_instance_level_ref_cache_isolation_via_provided_cache(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#", }, }, "required": ["name", "age"], } model1 = self.converter.build_with_cache(schema, ref_cache={}) model2 = self.converter.build_with_cache(schema, ref_cache={}) self.assertIsNot(model1, model2) def test_get_type_from_cache(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "emergency_contact": { "$ref": "#", }, }, "required": ["name", "age"], } model = self.converter.build_with_cache(schema) cached_model = self.converter.get_cached_ref("Person") self.assertIs(model, cached_model) def test_get_type_from_cache_not_found(self): cached_model = self.converter.get_cached_ref("NonExistentModel") self.assertIsNone(cached_model) def test_get_type_from_cache_nested_type(self): schema = { "title": "Person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "address": { "type": "object", "properties": { "street": {"type": "string"}, "city": {"type": "string"}, }, "required": ["street", "city"], }, }, "required": ["name", "age", "address"], } model = self.converter.build_with_cache(schema) cached_model = self.converter.get_cached_ref("Person.address") self.assertIsNotNone(cached_model) self.assertIs(model.model_fields["address"].annotation, cached_model) def test_get_type_from_cache_with_def(self): schema = { "title": "person", "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "address": {"$ref": "#/$defs/address"}, }, "$defs": { "address": { "type": "object", "properties": { "street": {"type": "string"}, "city": {"type": "string"}, }, "required": ["street", "city"], } }, } person_model = self.converter.build_with_cache(schema) cached_person_model = self.converter.get_cached_ref("person") self.assertIs(person_model, cached_person_model) cached_address_model = self.converter.get_cached_ref("address") self.assertIsNotNone(cached_address_model)