Merge pull request #105 from collerek/fastapi_docs
fix multiple pkonly models with same name in openapi schema
This commit is contained in:
@ -1,3 +1,9 @@
|
|||||||
|
# 0.9.4
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
* Fix `fastapi` OpenAPI schema generation for automatic docs when multiple models refer to the same related one
|
||||||
|
|
||||||
|
|
||||||
# 0.9.3
|
# 0.9.3
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class UndefinedType: # pragma no cover
|
|||||||
|
|
||||||
Undefined = UndefinedType()
|
Undefined = UndefinedType()
|
||||||
|
|
||||||
__version__ = "0.9.3"
|
__version__ = "0.9.4"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Integer",
|
"Integer",
|
||||||
"BigInteger",
|
"BigInteger",
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import string
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from random import choices
|
||||||
from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union
|
from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
@ -67,9 +69,14 @@ def create_dummy_model(
|
|||||||
:return: constructed dummy model
|
:return: constructed dummy model
|
||||||
:rtype: pydantic.BaseModel
|
:rtype: pydantic.BaseModel
|
||||||
"""
|
"""
|
||||||
|
alias = (
|
||||||
|
"".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4]
|
||||||
|
).lower()
|
||||||
fields = {f"{pk_field.name}": (pk_field.__type__, None)}
|
fields = {f"{pk_field.name}": (pk_field.__type__, None)}
|
||||||
dummy_model = create_model(
|
dummy_model = create_model(
|
||||||
f"PkOnly{base_model.get_name(lower=False)}", **fields # type: ignore
|
f"PkOnly{base_model.get_name(lower=False)}{alias}",
|
||||||
|
__module__=base_model.__module__,
|
||||||
|
**fields, # type: ignore
|
||||||
)
|
)
|
||||||
return dummy_model
|
return dummy_model
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,7 @@ if TYPE_CHECKING: # pragma no cover
|
|||||||
from ormar import Model
|
from ormar import Model
|
||||||
|
|
||||||
CONFIG_KEY = "Config"
|
CONFIG_KEY = "Config"
|
||||||
|
PARSED_FIELDS_KEY = "__parsed_fields__"
|
||||||
|
|
||||||
|
|
||||||
class ModelMeta:
|
class ModelMeta:
|
||||||
@ -141,83 +142,6 @@ def register_signals(new_model: Type["Model"]) -> None: # noqa: CCR001
|
|||||||
new_model.Meta.signals = signals
|
new_model.Meta.signals = signals
|
||||||
|
|
||||||
|
|
||||||
class ModelMetaclass(pydantic.main.ModelMetaclass):
|
|
||||||
def __new__( # type: ignore # noqa: CCR001
|
|
||||||
mcs: "ModelMetaclass", name: str, bases: Any, attrs: dict
|
|
||||||
) -> "ModelMetaclass":
|
|
||||||
"""
|
|
||||||
Metaclass used by ormar Models that performs configuration
|
|
||||||
and build of ormar Models.
|
|
||||||
|
|
||||||
|
|
||||||
Sets pydantic configuration.
|
|
||||||
Extract model_fields and convert them to pydantic FieldInfo,
|
|
||||||
updates class namespace.
|
|
||||||
|
|
||||||
Extracts settings and fields from parent classes.
|
|
||||||
Fetches methods decorated with @property_field decorator
|
|
||||||
to expose them later in dict().
|
|
||||||
|
|
||||||
Construct parent pydantic Metaclass/ Model.
|
|
||||||
|
|
||||||
If class has Meta class declared (so actual ormar Models) it also:
|
|
||||||
|
|
||||||
* populate sqlalchemy columns, pkname and tables from model_fields
|
|
||||||
* register reverse relationships on related models
|
|
||||||
* registers all relations in alias manager that populates table_prefixes
|
|
||||||
* exposes alias manager on each Model
|
|
||||||
* creates QuerySet for each model and exposes it on a class
|
|
||||||
|
|
||||||
:param name: name of current class
|
|
||||||
:type name: str
|
|
||||||
:param bases: base classes
|
|
||||||
:type bases: Tuple
|
|
||||||
:param attrs: class namespace
|
|
||||||
:type attrs: Dict
|
|
||||||
"""
|
|
||||||
attrs["Config"] = get_pydantic_base_orm_config()
|
|
||||||
attrs["__name__"] = name
|
|
||||||
attrs, model_fields = extract_annotations_and_default_vals(attrs)
|
|
||||||
for base in reversed(bases):
|
|
||||||
mod = base.__module__
|
|
||||||
if mod.startswith("ormar.models.") or mod.startswith("pydantic."):
|
|
||||||
continue
|
|
||||||
attrs, model_fields = extract_from_parents_definition(
|
|
||||||
base_class=base, curr_class=mcs, attrs=attrs, model_fields=model_fields
|
|
||||||
)
|
|
||||||
new_model = super().__new__( # type: ignore
|
|
||||||
mcs, name, bases, attrs
|
|
||||||
)
|
|
||||||
|
|
||||||
add_cached_properties(new_model)
|
|
||||||
|
|
||||||
if hasattr(new_model, "Meta"):
|
|
||||||
populate_default_options_values(new_model, model_fields)
|
|
||||||
check_required_meta_parameters(new_model)
|
|
||||||
add_property_fields(new_model, attrs)
|
|
||||||
register_signals(new_model=new_model)
|
|
||||||
populate_choices_validators(new_model)
|
|
||||||
|
|
||||||
if not new_model.Meta.abstract:
|
|
||||||
new_model = populate_meta_tablename_columns_and_pk(name, new_model)
|
|
||||||
populate_meta_sqlalchemy_table_if_required(new_model.Meta)
|
|
||||||
expand_reverse_relationships(new_model)
|
|
||||||
for field in new_model.Meta.model_fields.values():
|
|
||||||
register_relation_in_alias_manager(field=field)
|
|
||||||
|
|
||||||
if new_model.Meta.pkname not in attrs["__annotations__"]:
|
|
||||||
field_name = new_model.Meta.pkname
|
|
||||||
attrs["__annotations__"][field_name] = Optional[int] # type: ignore
|
|
||||||
attrs[field_name] = None
|
|
||||||
new_model.__fields__[field_name] = get_pydantic_field(
|
|
||||||
field_name=field_name, model=new_model
|
|
||||||
)
|
|
||||||
new_model.Meta.alias_manager = alias_manager
|
|
||||||
new_model.objects = QuerySet(new_model)
|
|
||||||
|
|
||||||
return new_model
|
|
||||||
|
|
||||||
|
|
||||||
def verify_constraint_names(
|
def verify_constraint_names(
|
||||||
base_class: "Model", model_fields: Dict, parent_value: List
|
base_class: "Model", model_fields: Dict, parent_value: List
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -539,4 +463,78 @@ def update_attrs_and_fields(
|
|||||||
return updated_model_fields
|
return updated_model_fields
|
||||||
|
|
||||||
|
|
||||||
PARSED_FIELDS_KEY = "__parsed_fields__"
|
class ModelMetaclass(pydantic.main.ModelMetaclass):
|
||||||
|
def __new__( # type: ignore # noqa: CCR001
|
||||||
|
mcs: "ModelMetaclass", name: str, bases: Any, attrs: dict
|
||||||
|
) -> "ModelMetaclass":
|
||||||
|
"""
|
||||||
|
Metaclass used by ormar Models that performs configuration
|
||||||
|
and build of ormar Models.
|
||||||
|
|
||||||
|
|
||||||
|
Sets pydantic configuration.
|
||||||
|
Extract model_fields and convert them to pydantic FieldInfo,
|
||||||
|
updates class namespace.
|
||||||
|
|
||||||
|
Extracts settings and fields from parent classes.
|
||||||
|
Fetches methods decorated with @property_field decorator
|
||||||
|
to expose them later in dict().
|
||||||
|
|
||||||
|
Construct parent pydantic Metaclass/ Model.
|
||||||
|
|
||||||
|
If class has Meta class declared (so actual ormar Models) it also:
|
||||||
|
|
||||||
|
* populate sqlalchemy columns, pkname and tables from model_fields
|
||||||
|
* register reverse relationships on related models
|
||||||
|
* registers all relations in alias manager that populates table_prefixes
|
||||||
|
* exposes alias manager on each Model
|
||||||
|
* creates QuerySet for each model and exposes it on a class
|
||||||
|
|
||||||
|
:param name: name of current class
|
||||||
|
:type name: str
|
||||||
|
:param bases: base classes
|
||||||
|
:type bases: Tuple
|
||||||
|
:param attrs: class namespace
|
||||||
|
:type attrs: Dict
|
||||||
|
"""
|
||||||
|
attrs["Config"] = get_pydantic_base_orm_config()
|
||||||
|
attrs["__name__"] = name
|
||||||
|
attrs, model_fields = extract_annotations_and_default_vals(attrs)
|
||||||
|
for base in reversed(bases):
|
||||||
|
mod = base.__module__
|
||||||
|
if mod.startswith("ormar.models.") or mod.startswith("pydantic."):
|
||||||
|
continue
|
||||||
|
attrs, model_fields = extract_from_parents_definition(
|
||||||
|
base_class=base, curr_class=mcs, attrs=attrs, model_fields=model_fields
|
||||||
|
)
|
||||||
|
new_model = super().__new__( # type: ignore
|
||||||
|
mcs, name, bases, attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
add_cached_properties(new_model)
|
||||||
|
|
||||||
|
if hasattr(new_model, "Meta"):
|
||||||
|
populate_default_options_values(new_model, model_fields)
|
||||||
|
check_required_meta_parameters(new_model)
|
||||||
|
add_property_fields(new_model, attrs)
|
||||||
|
register_signals(new_model=new_model)
|
||||||
|
populate_choices_validators(new_model)
|
||||||
|
|
||||||
|
if not new_model.Meta.abstract:
|
||||||
|
new_model = populate_meta_tablename_columns_and_pk(name, new_model)
|
||||||
|
populate_meta_sqlalchemy_table_if_required(new_model.Meta)
|
||||||
|
expand_reverse_relationships(new_model)
|
||||||
|
for field in new_model.Meta.model_fields.values():
|
||||||
|
register_relation_in_alias_manager(field=field)
|
||||||
|
|
||||||
|
if new_model.Meta.pkname not in attrs["__annotations__"]:
|
||||||
|
field_name = new_model.Meta.pkname
|
||||||
|
attrs["__annotations__"][field_name] = Optional[int] # type: ignore
|
||||||
|
attrs[field_name] = None
|
||||||
|
new_model.__fields__[field_name] = get_pydantic_field(
|
||||||
|
field_name=field_name, model=new_model
|
||||||
|
)
|
||||||
|
new_model.Meta.alias_manager = alias_manager
|
||||||
|
new_model.objects = QuerySet(new_model)
|
||||||
|
|
||||||
|
return new_model
|
||||||
|
|||||||
72
tests/test_docs_with_multiple_relations_to_one.py
Normal file
72
tests/test_docs_with_multiple_relations_to_one.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import databases
|
||||||
|
import sqlalchemy
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import ormar
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
DATABASE_URL = "sqlite:///db.sqlite"
|
||||||
|
database = databases.Database(DATABASE_URL)
|
||||||
|
metadata = sqlalchemy.MetaData()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMeta(ormar.ModelMeta):
|
||||||
|
metadata = metadata
|
||||||
|
database = database
|
||||||
|
|
||||||
|
|
||||||
|
class CA(ormar.Model):
|
||||||
|
class Meta(BaseMeta):
|
||||||
|
tablename = "cas"
|
||||||
|
|
||||||
|
id: UUID = ormar.UUID(primary_key=True, default=uuid4)
|
||||||
|
ca_name: str = ormar.Text(default="")
|
||||||
|
|
||||||
|
|
||||||
|
class CB1(ormar.Model):
|
||||||
|
class Meta(BaseMeta):
|
||||||
|
tablename = "cb1s"
|
||||||
|
|
||||||
|
id: UUID = ormar.UUID(primary_key=True, default=uuid4)
|
||||||
|
cb1_name: str = ormar.Text(default="")
|
||||||
|
ca1: Optional[CA] = ormar.ForeignKey(CA, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CB2(ormar.Model):
|
||||||
|
class Meta(BaseMeta):
|
||||||
|
tablename = "cb2s"
|
||||||
|
|
||||||
|
id: UUID = ormar.UUID(primary_key=True, default=uuid4)
|
||||||
|
cb2_name: str = ormar.Text(default="")
|
||||||
|
ca2: Optional[CA] = ormar.ForeignKey(CA, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ca", response_model=CA)
|
||||||
|
async def get_ca(): # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/cb1", response_model=CB1)
|
||||||
|
async def get_cb1(): # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/cb2", response_model=CB2)
|
||||||
|
async def get_cb2(): # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_endpoints():
|
||||||
|
client = TestClient(app)
|
||||||
|
with client as client:
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
schema = response.json()
|
||||||
|
components = schema["components"]["schemas"]
|
||||||
|
assert all(x in components for x in ["CA", "CB1", "CB2"])
|
||||||
|
pk_onlys = [x for x in list(components.keys()) if x.startswith("PkOnly")]
|
||||||
|
assert len(pk_onlys) == 2
|
||||||
Reference in New Issue
Block a user