From 8c6d0988880f5d9186842e90aa31a26a26efac93 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Sun, 24 Nov 2024 19:04:45 +0300 Subject: [PATCH 1/4] Add support for custom JSON Schema instance formats defined by users --- mashumaro/jsonschema/models.py | 17 +++- .../test_jsonschema_generation.py | 85 ++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/mashumaro/jsonschema/models.py b/mashumaro/jsonschema/models.py index b194ccc..973ba20 100644 --- a/mashumaro/jsonschema/models.py +++ b/mashumaro/jsonschema/models.py @@ -8,6 +8,7 @@ from typing_extensions import TYPE_CHECKING, Self, TypeAlias from mashumaro.config import BaseConfig +from mashumaro.core.meta.helpers import iter_all_subclasses from mashumaro.helper import pass_through from mashumaro.jsonschema.dialects import DRAFT_2020_12, JSONSchemaDialect @@ -96,6 +97,15 @@ class JSONSchemaInstanceFormatExtension(JSONSchemaInstanceFormat): } +def _deserialize_json_schema_instance_format(value): + for cls in iter_all_subclasses(JSONSchemaInstanceFormat): + try: + return cls(value) + except (ValueError, TypeError): + pass + raise ValueError(value) + + @dataclass(unsafe_hash=True) class JSONSchema(DataClassJSONMixin): # Common keywords @@ -103,9 +113,7 @@ class JSONSchema(DataClassJSONMixin): type: Optional[JSONSchemaInstanceType] = None enum: Optional[list[Any]] = None const: Optional[Any] = field(default_factory=lambda: MISSING) - format: Optional[ - Union[JSONSchemaStringFormat, JSONSchemaInstanceFormatExtension] - ] = None + format: Optional[JSONSchemaInstanceFormat] = None title: Optional[str] = None description: Optional[str] = None anyOf: Optional[List["JSONSchema"]] = None @@ -157,6 +165,9 @@ class Config(BaseConfig): int: pass_through, float: pass_through, Null: pass_through, + JSONSchemaInstanceFormat: { + "deserialize": _deserialize_json_schema_instance_format, + } } def __pre_serialize__(self) -> Self: diff --git a/tests/test_jsonschema/test_jsonschema_generation.py b/tests/test_jsonschema/test_jsonschema_generation.py index 15b594f..2d55841 100644 --- a/tests/test_jsonschema/test_jsonschema_generation.py +++ b/tests/test_jsonschema/test_jsonschema_generation.py @@ -65,14 +65,21 @@ from mashumaro.jsonschema.builder import JSONSchemaBuilder, build_json_schema from mashumaro.jsonschema.dialects import DRAFT_2020_12, OPEN_API_3_1 from mashumaro.jsonschema.models import ( + BasePlugin, + Context, JSONArraySchema, JSONObjectSchema, JSONSchema, + JSONSchemaInstanceFormat, JSONSchemaInstanceFormatExtension, JSONSchemaInstanceType, JSONSchemaStringFormat, ) -from mashumaro.jsonschema.schema import UTC_OFFSET_PATTERN, EmptyJSONSchema +from mashumaro.jsonschema.schema import ( + UTC_OFFSET_PATTERN, + EmptyJSONSchema, + Instance, +) from mashumaro.types import Discriminator, SerializationStrategy from tests.entities import ( CustomPath, @@ -1354,3 +1361,79 @@ class Main: additionalProperties=False, ) assert build_json_schema(Main) == schema + + +def test_jsonschema_with_custom_instance_format(): + class CustomJSONSchemaInstanceFormatPlugin(BasePlugin): + def get_schema( + self, + instance: Instance, + ctx: Context, + schema: Optional[JSONSchema] = None, + ) -> Optional[JSONSchema]: + for annotation in instance.annotations: + if isinstance(annotation, JSONSchemaInstanceFormat): + schema.format = annotation + return schema + + class Custom1InstanceFormat(JSONSchemaInstanceFormat): + CUSTOM1 = "custom1" + + class CustomInstanceFormatBase(JSONSchemaInstanceFormat): + pass + + class Custom2InstanceFormat(CustomInstanceFormatBase): + CUSTOM2 = "custom2" + + type1 = Annotated[str, Custom1InstanceFormat.CUSTOM1] + schema1 = build_json_schema( + type1, plugins=[CustomJSONSchemaInstanceFormatPlugin()] + ) + assert schema1.format is Custom1InstanceFormat.CUSTOM1 + assert schema1.to_dict()["format"] == "custom1" + + type2 = Annotated[int, Custom2InstanceFormat.CUSTOM2] + schema2 = build_json_schema( + type2, plugins=[CustomJSONSchemaInstanceFormatPlugin()] + ) + assert schema2.format is Custom2InstanceFormat.CUSTOM2 + assert schema2.to_dict()["format"] == "custom2" + + assert ( + JSONSchema.from_dict({"format": "custom1"}).format + is Custom1InstanceFormat.CUSTOM1 + ) + assert ( + JSONSchema.from_dict({"format": "custom2"}).format + is Custom2InstanceFormat.CUSTOM2 + ) + + @dataclass + class MyClass: + x: str + y: str + + class Config(BaseConfig): + json_schema = { + "properties": { + "x": {"type": "string", "format": "custom1"}, + "y": {"type": "string", "format": "custom2"}, + } + } + + schema3 = build_json_schema(MyClass) + assert schema3 == JSONObjectSchema( + title="MyClass", + properties={ + "x": JSONSchema( + type=JSONSchemaInstanceType.STRING, + format=Custom1InstanceFormat.CUSTOM1, + ), + "y": JSONSchema( + type=JSONSchemaInstanceType.STRING, + format=Custom2InstanceFormat.CUSTOM2, + ), + }, + required=["x", "y"], + additionalProperties=False, + ) From 776959a8bc3cce7d149149d08c64e3d180c66536 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Sun, 24 Nov 2024 19:11:56 +0300 Subject: [PATCH 2/4] Add type annotations --- mashumaro/jsonschema/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mashumaro/jsonschema/models.py b/mashumaro/jsonschema/models.py index 973ba20..c68e08a 100644 --- a/mashumaro/jsonschema/models.py +++ b/mashumaro/jsonschema/models.py @@ -97,7 +97,9 @@ class JSONSchemaInstanceFormatExtension(JSONSchemaInstanceFormat): } -def _deserialize_json_schema_instance_format(value): +def _deserialize_json_schema_instance_format( + value: Any, +) -> JSONSchemaInstanceFormat: for cls in iter_all_subclasses(JSONSchemaInstanceFormat): try: return cls(value) @@ -167,7 +169,7 @@ class Config(BaseConfig): Null: pass_through, JSONSchemaInstanceFormat: { "deserialize": _deserialize_json_schema_instance_format, - } + }, } def __pre_serialize__(self) -> Self: From 15374ae0f39f9edb2a49d4930e203dcba50a1e69 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Sun, 24 Nov 2024 19:14:26 +0300 Subject: [PATCH 3/4] Fix import --- tests/test_jsonschema/test_jsonschema_generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_jsonschema/test_jsonschema_generation.py b/tests/test_jsonschema/test_jsonschema_generation.py index 2d55841..9ffd4a0 100644 --- a/tests/test_jsonschema/test_jsonschema_generation.py +++ b/tests/test_jsonschema/test_jsonschema_generation.py @@ -65,7 +65,6 @@ from mashumaro.jsonschema.builder import JSONSchemaBuilder, build_json_schema from mashumaro.jsonschema.dialects import DRAFT_2020_12, OPEN_API_3_1 from mashumaro.jsonschema.models import ( - BasePlugin, Context, JSONArraySchema, JSONObjectSchema, @@ -75,6 +74,7 @@ JSONSchemaInstanceType, JSONSchemaStringFormat, ) +from mashumaro.jsonschema.plugins import BasePlugin from mashumaro.jsonschema.schema import ( UTC_OFFSET_PATTERN, EmptyJSONSchema, From 039597289e4f1c13774ac017aea491b5fdfd3f53 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Sun, 24 Nov 2024 19:30:46 +0300 Subject: [PATCH 4/4] Increase coverage --- tests/test_jsonschema/test_json_schema_common.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_jsonschema/test_json_schema_common.py b/tests/test_jsonschema/test_json_schema_common.py index 9b39e1c..9e2a8f5 100644 --- a/tests/test_jsonschema/test_json_schema_common.py +++ b/tests/test_jsonschema/test_json_schema_common.py @@ -1,4 +1,10 @@ +import pytest + from mashumaro.config import BaseConfig +from mashumaro.jsonschema.models import ( + JSONSchemaStringFormat, + _deserialize_json_schema_instance_format, +) from mashumaro.jsonschema.schema import Instance @@ -9,3 +15,12 @@ def test_instance_get_configs(): derived = instance.derive() assert derived.get_self_config() is instance.get_self_config() + + +def test_deserialize_json_schema_instance_format(): + assert ( + _deserialize_json_schema_instance_format("email") + is JSONSchemaStringFormat.EMAIL + ) + with pytest.raises(ValueError): + assert _deserialize_json_schema_instance_format("foobar")