Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a plugin system to json schema #265

Merged
merged 7 commits into from
Nov 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Table of contents
* [JSON Schema](#json-schema)
* [Building JSON Schema](#building-json-schema)
* [JSON Schema constraints](#json-schema-constraints)
* [JSON Schema plugins](#json-schema-plugins)
* [Extending JSON Schema](#extending-json-schema)
* [JSON Schema and custom serialization methods](#json-schema-and-custom-serialization-methods)

Expand Down Expand Up @@ -3283,6 +3284,129 @@ Object constraints:
* [`MinProperties`](https://json-schema.org/draft/2020-12/json-schema-validation.html#name-minproperties)
* [`DependentRequired`](https://json-schema.org/draft/2020-12/json-schema-validation.html#name-dependentrequired)

### JSON Schema plugins

If the built-in functionality doesn't meet your needs, you can customize the JSON Schema generation or add support for additional types using plugins. The `mashumaro.jsonschema.plugins.BasePlugin` class provides a `get_schema` method that you can override to implement custom behavior.

The plugin system works by iterating through all registered plugins and calling their `get_schema` methods. If a plugin's `get_schema` method raises a `NotImplementedError` or returns `None`, it indicates that the plugin doesn't provide the required functionality for that particular case.

You can apply multiple plugins sequentially, allowing each to modify the schema in turn. This approach enables a step-by-step transformation of the schema, with each plugin contributing its specific modifications.

Plugins can be registered using the `plugins` argument in either the `build_json_schema` function or the `JSONSchemaBuilder` class.

The `mashumaro.jsonschema.plugins` module contains several built-in plugins. Currently, one of these plugins adds descriptions to JSON schemas using docstrings from dataclasses:

```python
from dataclasses import dataclass

from mashumaro.jsonschema import build_json_schema
from mashumaro.jsonschema.plugins import DocstringDescriptionPlugin


@dataclass
class MyClass:
"""My class"""

x: int


schema = build_json_schema(MyClass, plugins=[DocstringDescriptionPlugin()])
print(schema.to_json())
```

<details>
<summary>Click to show the result</summary>

```json
{
"type": "object",
"title": "MyClass",
"description": "My class",
"properties": {
"x": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"x"
]
}
```
</details>

Creating your own custom plugin is straightforward. For instance, if you want to add support for Pydantic models, you could write a plugin similar to the following:

```python
from dataclasses import dataclass

from pydantic import BaseModel

from mashumaro.jsonschema import build_json_schema
from mashumaro.jsonschema.models import Context, JSONSchema
from mashumaro.jsonschema.plugins import BasePlugin
from mashumaro.jsonschema.schema import Instance


class PydanticSchemaPlugin(BasePlugin):
def get_schema(
self,
instance: Instance,
ctx: Context,
schema: JSONSchema | None = None,
) -> JSONSchema | None:
try:
if issubclass(instance.type, BaseModel):
pydantic_schema = instance.type.model_json_schema()
return JSONSchema.from_dict(pydantic_schema)
except TypeError:
return None


class MyPydanticClass(BaseModel):
x: int


@dataclass
class MyDataClass:
y: MyPydanticClass


schema = build_json_schema(MyDataClass, plugins=[PydanticSchemaPlugin()])
print(schema.to_json())
```

<details>
<summary>Click to show the result</summary>

```json
{
"type": "object",
"title": "MyDataClass",
"properties": {
"y": {
"type": "object",
"title": "MyPydanticClass",
"properties": {
"x": {
"type": "integer",
"title": "X"
}
},
"required": [
"x"
]
}
},
"additionalProperties": false,
"required": [
"y"
]
}
```
</details>


### Extending JSON Schema

Using a `Config` class it is possible to override some parts of the schema.
Expand Down
8 changes: 8 additions & 0 deletions mashumaro/jsonschema/builder.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Optional, Type

from mashumaro.jsonschema.dialects import DRAFT_2020_12, JSONSchemaDialect
from mashumaro.jsonschema.models import Context, JSONSchema
from mashumaro.jsonschema.plugins import BasePlugin
from mashumaro.jsonschema.schema import Instance, get_schema

try:
Expand All @@ -21,6 +23,7 @@ def build_json_schema(
with_dialect_uri: bool = False,
dialect: Optional[JSONSchemaDialect] = None,
ref_prefix: Optional[str] = None,
plugins: Sequence[BasePlugin] = (),
) -> JSONSchema:
if context is None:
context = Context()
Expand All @@ -30,6 +33,7 @@ def build_json_schema(
definitions=context.definitions,
all_refs=context.all_refs,
ref_prefix=context.ref_prefix,
plugins=context.plugins,
)
if dialect is not None:
context.dialect = dialect
Expand All @@ -41,6 +45,8 @@ def build_json_schema(
context.ref_prefix = ref_prefix.rstrip("/")
elif context.ref_prefix is None:
context.ref_prefix = context.dialect.definitions_root_pointer
if plugins:
context.plugins = plugins
instance = Instance(instance_type)
schema = get_schema(instance, context, with_dialect_uri=with_dialect_uri)
if with_definitions and context.definitions:
Expand All @@ -64,6 +70,7 @@ def __init__(
dialect: JSONSchemaDialect = DRAFT_2020_12,
all_refs: Optional[bool] = None,
ref_prefix: Optional[str] = None,
plugins: Sequence[BasePlugin] = (),
):
if all_refs is None:
all_refs = dialect.all_refs
Expand All @@ -73,6 +80,7 @@ def __init__(
dialect=dialect,
all_refs=all_refs,
ref_prefix=ref_prefix.rstrip("/"),
plugins=plugins,
)

def build(self, instance_type: Type) -> JSONSchema:
Expand Down
9 changes: 8 additions & 1 deletion mashumaro/jsonschema/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import datetime
import ipaddress
from collections.abc import Sequence
from dataclasses import MISSING, dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Union

from typing_extensions import Self, TypeAlias
from typing_extensions import TYPE_CHECKING, Self, TypeAlias

from mashumaro.config import BaseConfig
from mashumaro.helper import pass_through
from mashumaro.jsonschema.dialects import DRAFT_2020_12, JSONSchemaDialect

if TYPE_CHECKING: # pragma: no cover
from mashumaro.jsonschema.plugins import BasePlugin
else:
BasePlugin = Any

try:
from mashumaro.mixins.orjson import (
DataClassORJSONMixin as DataClassJSONMixin,
Expand Down Expand Up @@ -190,3 +196,4 @@ class Context:
definitions: dict[str, JSONSchema] = field(default_factory=dict)
all_refs: Optional[bool] = None
ref_prefix: Optional[str] = None
plugins: Sequence[BasePlugin] = ()
28 changes: 28 additions & 0 deletions mashumaro/jsonschema/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from dataclasses import is_dataclass
from inspect import cleandoc
from typing import Optional

from mashumaro.jsonschema.models import Context, JSONSchema
from mashumaro.jsonschema.schema import Instance


class BasePlugin:
def get_schema(
self,
instance: Instance,
ctx: Context,
schema: Optional[JSONSchema] = None,
) -> Optional[JSONSchema]:
pass


class DocstringDescriptionPlugin(BasePlugin):
def get_schema(
self,
instance: Instance,
ctx: Context,
schema: Optional[JSONSchema] = None,
) -> Optional[JSONSchema]:
if schema and is_dataclass(instance.type) and instance.type.__doc__:
schema.description = cleandoc(instance.type.__doc__)
return None
16 changes: 15 additions & 1 deletion mashumaro/jsonschema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ class Instance:
__owner_builder: Optional[CodeBuilder] = None
__self_builder: Optional[CodeBuilder] = None

# Original type despite custom serialization. To be revised.
_original_type: Type = field(init=False)

origin_type: Type = field(init=False)
annotations: list[Annotation] = field(init=False, default_factory=list)

Expand Down Expand Up @@ -150,6 +153,7 @@ def derive(self, **changes: Any) -> "Instance":
return new_instance

def __post_init__(self) -> None:
self._original_type = self.type
self.update_type(self.type)
if is_annotated(self.type):
self.annotations = getattr(self.type, "__metadata__", [])
Expand Down Expand Up @@ -260,12 +264,22 @@ class EmptyJSONSchema(JSONSchema):
def get_schema(
instance: Instance, ctx: Context, with_dialect_uri: bool = False
) -> JSONSchema:
schema = None
for schema_creator in Registry.iter():
schema = schema_creator(instance, ctx)
if schema is not None:
if with_dialect_uri:
schema.schema = ctx.dialect.uri
return schema
break
for plugin in ctx.plugins:
try:
new_schema = plugin.get_schema(instance, ctx, schema)
if new_schema:
schema = new_schema
except NotImplementedError:
continue
if schema:
return schema
raise NotImplementedError(
f'Type {type_name(instance.type)} of field "{instance.name}" '
f"in {type_name(instance.owner_class)} isn't supported"
Expand Down
Loading
Loading