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

Update to Pydantic 2 #33

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
67 changes: 33 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ open_api = OpenAPI(
)
},
)
print(open_api.json(by_alias=True, exclude_none=True, indent=2))
print(open_api.model_dump_json(by_alias=True, exclude_none=True, indent=2))
```

Result:
Expand Down Expand Up @@ -69,15 +69,14 @@ Result:

## Take advantage of Pydantic

Pydantic is a great tool, allow you to use object / dict / mixed data for for input.

Pydantic is a great tool. It allows you to use object / dict / mixed data for input.
The following examples give the same OpenAPI result as above:

```python
from openapi_schema_pydantic import OpenAPI, PathItem, Response

# Construct OpenAPI from dict
open_api = OpenAPI.parse_obj({
open_api = OpenAPI.model_validate({
"info": {"title": "My own API", "version": "v0.0.1"},
"paths": {
"/ping": {
Expand All @@ -87,7 +86,7 @@ open_api = OpenAPI.parse_obj({
})

# Construct OpenAPI with mix of dict/object
open_api = OpenAPI.parse_obj({
open_api = OpenAPI.model_validate({
"info": {"title": "My own API", "version": "v0.0.1"},
"paths": {
"/ping": PathItem(
Expand All @@ -100,10 +99,10 @@ open_api = OpenAPI.parse_obj({
## Use Pydantic classes as schema

- The [Schema Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#schemaObject)
in OpenAPI has definitions and tweaks in JSON Schema, which is hard to comprehend and define a good data class
in OpenAPI has definitions and tweaks in JSON Schema, which are hard to comprehend and define a good data class
- Pydantic already has a good way to [create JSON schema](https://pydantic-docs.helpmanual.io/usage/schema/),
let's not re-invent the wheel

The approach to deal with this:

1. Use `PydanticSchema` objects to represent the `Schema` in `OpenAPI` object
Expand All @@ -116,7 +115,7 @@ from openapi_schema_pydantic import OpenAPI
from openapi_schema_pydantic.util import PydanticSchema, construct_open_api_with_schema_class

def construct_base_open_api() -> OpenAPI:
return OpenAPI.parse_obj({
return OpenAPI.model_validate({
"info": {"title": "My own API", "version": "v0.0.1"},
"paths": {
"/ping": {
Expand Down Expand Up @@ -148,8 +147,8 @@ class PingResponse(BaseModel):
open_api = construct_base_open_api()
open_api = construct_open_api_with_schema_class(open_api)

# print the result openapi.json
print(open_api.json(by_alias=True, exclude_none=True, indent=2))
# print the result of openapi.model_dump_json()
print(open_api.model_dump_json(by_alias=True, exclude_none=True, indent=2))
```

Result:
Expand Down Expand Up @@ -198,45 +197,45 @@ Result:
"components": {
"schemas": {
"PingRequest": {
"title": "PingRequest",
"required": [
"req_foo",
"req_bar"
],
"type": "object",
"properties": {
"req_foo": {
"title": "Req Foo",
"type": "string",
"title": "Req Foo",
"description": "foo value of the request"
},
"req_bar": {
"title": "Req Bar",
"type": "string",
"title": "Req Bar",
"description": "bar value of the request"
}
},
"type": "object",
"required": [
"req_foo",
"req_bar"
],
"title": "PingRequest",
"description": "Ping Request"
},
"PingResponse": {
"title": "PingResponse",
"required": [
"resp_foo",
"resp_bar"
],
"type": "object",
"properties": {
"resp_foo": {
"title": "Resp Foo",
"type": "string",
"title": "Resp Foo",
"description": "foo value of the response"
},
"resp_bar": {
"title": "Resp Bar",
"type": "string",
"title": "Resp Bar",
"description": "bar value of the response"
}
},
"type": "object",
"required": [
"resp_foo",
"resp_bar"
],
"title": "PingResponse",
"description": "Ping response"
}
}
Expand All @@ -246,21 +245,21 @@ Result:

## Notes

### Use of OpenAPI.json() / OpenAPI.dict()
### Use of OpenAPI.model_dump_json() / OpenAPI.model_dump()

When using `OpenAPI.json()` / `OpenAPI.dict()` function,
arguments `by_alias=True, exclude_none=True` has to be in place.
Otherwise the result json will not fit the OpenAPI standard.
When using `OpenAPI.model_dump_json()` / `OpenAPI.model_dump()` functions,
the arguments `by_alias=True, exclude_none=True` have to be in place,
otherwise the resulting json will not fit the OpenAPI standard.

```python
# OK
open_api.json(by_alias=True, exclude_none=True, indent=2)
open_api.model_dump_json(by_alias=True, exclude_none=True, indent=2)

# Not good
open_api.json(indent=2)
open_api.model_dump_json(indent=2)
```

More info about field alias:
More info about field aliases:

| OpenAPI version | Field alias info |
| --------------- | ---------------- |
Expand All @@ -280,7 +279,7 @@ Please refer to the following for more info:
### Use OpenAPI 3.0.3 instead of 3.1.0

Some UI renderings (e.g. Swagger) still do not support OpenAPI 3.1.0.
It is allowed to use the old 3.0.3 version by importing from different paths:
The old 3.0.3 version is available by importing from different paths:

```python
from openapi_schema_pydantic.v3.v3_0_3 import OpenAPI, ...
Expand Down
39 changes: 25 additions & 14 deletions openapi_schema_pydantic/util.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
import logging
from typing import Any, List, Set, Type, TypeVar
from typing import Any, List, Set, Type

from pydantic import BaseModel
from pydantic.schema import schema
from pydantic import BaseModel, create_model
from pydantic.json_schema import models_json_schema, JsonSchemaMode

from . import Components, OpenAPI, Reference, Schema

logger = logging.getLogger(__name__)

PydanticType = TypeVar("PydanticType", bound=BaseModel)
PydanticType = BaseModel
ref_prefix = "#/components/schemas/"
ref_template = ref_prefix + "{model}"


class PydanticSchema(Schema):
"""Special `Schema` class to indicate a reference from pydantic class"""

schema_class: Type[PydanticType] = ...
schema_class: Type[BaseModel]
"""the class that is used for generate the schema"""


def get_mode(cls: Type[BaseModel], default: JsonSchemaMode = "validation") -> JsonSchemaMode:
if not hasattr(cls, "model_config"):
return default
return cls.model_config.get("json_schema_mode", default)


def construct_open_api_with_schema_class(
open_api: OpenAPI,
schema_classes: List[Type[PydanticType]] = None,
schema_classes: List[Type[BaseModel]] | None = None,
scan_for_pydantic_schema_reference: bool = True,
by_alias: bool = True,
) -> OpenAPI:
Expand All @@ -36,7 +43,7 @@ def construct_open_api_with_schema_class(
:return: new OpenAPI object with "#/components/schemas" values updated.
If there is no update in "#/components/schemas" values, the original `open_api` will be returned.
"""
new_open_api: OpenAPI = open_api.copy(deep=True)
new_open_api: OpenAPI = open_api.model_copy(deep=True)
if scan_for_pydantic_schema_reference:
extracted_schema_classes = _handle_pydantic_schema(new_open_api)
if schema_classes:
Expand All @@ -51,27 +58,31 @@ def construct_open_api_with_schema_class(
logger.debug(f"schema_classes{schema_classes}")

# update new_open_api with new #/components/schemas
schema_definitions = schema(schema_classes, by_alias=by_alias, ref_prefix=ref_prefix)
key_map, schema_definitions = models_json_schema(
[(c, get_mode(c)) for c in schema_classes],
by_alias=by_alias,
ref_template=ref_template,
)
if not new_open_api.components:
new_open_api.components = Components()
if new_open_api.components.schemas:
for existing_key in new_open_api.components.schemas:
if existing_key in schema_definitions.get("definitions"):
if existing_key in schema_definitions["$defs"]:
logger.warning(
f'"{existing_key}" already exists in {ref_prefix}. '
f'The value of "{ref_prefix}{existing_key}" will be overwritten.'
)
new_open_api.components.schemas.update(
{key: Schema.parse_obj(schema_dict) for key, schema_dict in schema_definitions.get("definitions").items()}
{key: Schema.model_validate(schema_dict) for key, schema_dict in schema_definitions["$defs"].items()}
)
else:
new_open_api.components.schemas = {
key: Schema.parse_obj(schema_dict) for key, schema_dict in schema_definitions.get("definitions").items()
key: Schema.model_validate(schema_dict) for key, schema_dict in schema_definitions["$defs"].items()
}
return new_open_api


def _handle_pydantic_schema(open_api: OpenAPI) -> List[Type[PydanticType]]:
def _handle_pydantic_schema(open_api: OpenAPI) -> List[Type[BaseModel]]:
"""
This function traverses the `OpenAPI` object and

Expand All @@ -84,11 +95,11 @@ def _handle_pydantic_schema(open_api: OpenAPI) -> List[Type[PydanticType]]:
:return: a list of schema classes extracted from `PydanticSchema` objects
"""

pydantic_types: Set[Type[PydanticType]] = set()
pydantic_types: Set[Type[BaseModel]] = set()

def _traverse(obj: Any):
if isinstance(obj, BaseModel):
fields = obj.__fields_set__
fields = obj.model_fields_set
for field in fields:
child_obj = obj.__getattribute__(field)
if isinstance(child_obj, PydanticSchema):
Expand Down
2 changes: 1 addition & 1 deletion openapi_schema_pydantic/v3/v3_0_3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ the following fields are used with [alias](https://pydantic-docs.helpmanual.io/u
> <a name="header_param_in"></a>The "in" field in Header object is actually a constant (`{"in": "header"}`).

> For convenience of object creation, the classes mentioned in above
> has configured `allow_population_by_field_name=True`.
> has configured `populate_by_name=True`.
>
> Reference: [Pydantic's Model Config](https://pydantic-docs.helpmanual.io/usage/model_config/)

Expand Down
6 changes: 4 additions & 2 deletions openapi_schema_pydantic/v3/v3_0_3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,7 @@


# resolve forward references
Encoding.update_forward_refs(Header=Header)
Schema.update_forward_refs()
Encoding.model_rebuild()
OpenAPI.model_rebuild()
Components.model_rebuild()
Operation.model_rebuild()
11 changes: 6 additions & 5 deletions openapi_schema_pydantic/v3/v3_0_3/components.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Dict, Optional, Union

from pydantic import BaseModel, Extra
from pydantic import BaseModel, ConfigDict

from .callback import Callback
from .example import Example
Expand Down Expand Up @@ -48,9 +48,9 @@ class Components(BaseModel):
callbacks: Optional[Dict[str, Union[Callback, Reference]]] = None
"""An object to hold reusable [Callback Objects](#callbackObject)."""

class Config:
extra = Extra.ignore
schema_extra = {
model_config = ConfigDict(
extra="ignore",
json_schema_extra={
"examples": [
{
"schemas": {
Expand Down Expand Up @@ -111,4 +111,5 @@ class Config:
},
}
]
}
},
)
11 changes: 6 additions & 5 deletions openapi_schema_pydantic/v3/v3_0_3/contact.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional

from pydantic import AnyUrl, BaseModel, Extra
from pydantic import AnyUrl, BaseModel, ConfigDict


class Contact(BaseModel):
Expand All @@ -25,10 +25,11 @@ class Contact(BaseModel):
MUST be in the format of an email address.
"""

class Config:
extra = Extra.ignore
schema_extra = {
model_config = ConfigDict(
extra="ignore",
json_schema_extra={
"examples": [
{"name": "API Support", "url": "http://www.example.com/support", "email": "support@example.com"}
]
}
},
)
13 changes: 7 additions & 6 deletions openapi_schema_pydantic/v3/v3_0_3/discriminator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Dict, Optional

from pydantic import BaseModel, Extra
from pydantic import BaseModel, Field, ConfigDict


class Discriminator(BaseModel):
Expand All @@ -14,7 +14,7 @@ class Discriminator(BaseModel):
When using the discriminator, _inline_ schemas will not be considered.
"""

propertyName: str = ...
propertyName: str
"""
**REQUIRED**. The name of the property in the payload that will hold the discriminator value.
"""
Expand All @@ -24,9 +24,9 @@ class Discriminator(BaseModel):
An object to hold mappings between payload values and schema names or references.
"""

class Config:
extra = Extra.ignore
schema_extra = {
model_config = ConfigDict(
extra="ignore",
json_schema_extra={
"examples": [
{
"propertyName": "petType",
Expand All @@ -36,4 +36,5 @@ class Config:
},
}
]
}
},
)
Loading