fastapi
fastapi copied to clipboard
Union produces anyOf instead of oneOf for OpenAPI generation
First Check
- [X] I added a very descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the FastAPI documentation, with the integrated search.
- [X] I already searched in Google "How to X in FastAPI" and didn't find any information.
- [X] I already read and followed all the tutorial in the docs and didn't find an answer.
- [X] I already checked if it is not related to FastAPI but to Pydantic.
- [X] I already checked if it is not related to FastAPI but to Swagger UI.
- [X] I already checked if it is not related to FastAPI but to ReDoc.
Commit to Help
- [X] I commit to help with one of those options 👆
Example Code
from typing import Literal, Union
from pydantic import BaseModel, Field
class Cat(BaseModel):
pet_type: Literal['cat']
meows: int
class Dog(BaseModel):
pet_type: Literal['dog']
barks: float
class Pet(BaseModel):
__root__: Union[Cat, Dog] = Field(..., discriminator='pet_type')
Pet.schema_json()
Description
Hi all,
Example from https://pydantic-docs.helpmanual.io/usage/types/#discriminated-unions-aka-tagged-unions:
Using the class Pet above in FastAPI to allow either Cat or Dog as an input leads to an OpenAPI.json which makes Pet like (printed as yaml)
Pet:
anyOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
discriminator:
propertyName: pet_type
both OpenApi (https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/) and the specification https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#discriminatorObject linked by the pydantic docs (link on top) state that it should be oneOf.
I think it's a pydantic issue, But there was already an issue for pydantic which was closed without resolve: https://github.com/samuelcolvin/pydantic/issues/656 So I open it here, because it may be more relevant for FastAPI, since it leads to an incorrect OpenAPI file. Let me know if you think differently.
Best Stefan
Operating System
Linux, Windows
Operating System Details
No response
FastAPI Version
0.75.0
Python Version
Python 3.9.7
Additional Context
No response
While this might be a valid issue (I also encountered this bug and am currently using some ugly workarounds :grimacing:), the problem has nothing to do with fastapi
. Schema generation is directly done by pydantic
, and fastapi has no influence on the result. I know that the previous pydantic issue was closed by the author, but I still think its a valid issue, so feel free to open a new one there. If no change is possible for backwards compatibility reasons, I would at least like to see a configuration parameter or similar.
I ran into this problem as well when using openapi-generator to generate a TypeScript SDK from an anyOf
discriminated union. The generated code looked incorrect when my models used anyOf
, but worked just fine once I switched to oneOf
.
My workaround was to modify our openapi schema post-generation using the following code:
# openapi_utils.py
def sub_oneof_for_anyof_in_discriminated_unions(openapi_schema: Dict[str, Any]) -> None:
"""
Modify the provided OpenAPI schema so that all models that represent a discriminated union use the `oneOf`
polymorphic behavior as opposed to the `anyOf` behavior that is auto-generated by FastAPI.
For example, the auto-generated object:
Pet:
anyOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
discriminator:
propertyName: pet_type
would become:
Pet:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
discriminator:
propertyName: pet_type
:param openapi_schema: A dict representation of a full OpenAPI spec
"""
for schema_model in openapi_schema["components"]["schemas"].values():
_sub_oneof_for_anyof_in_discriminated_unions_recursive_helper(schema_model)
def _sub_oneof_for_anyof_in_discriminated_unions_recursive_helper(obj: Dict[str, Any]) -> None:
"""
Recursive helper function for `sub_oneof_for_anyof_in_discriminated_unions()`.
Given a dict of an openapi schema object such as `{"type": object", "properties": [...]}`, check to see if
it is an `anyOf` discriminated union. We define an `anyOf` openapi discriminated union as an object that:
1. Has an `anyOf` key
2. Has a `discriminator` key
If the object is a discriminated union, substitute the `oneOf` key in place of the `anyOf`. After checking the
provided object and making a substitution if necessary, proceed to recur on any sub-properties of that object
and apply the same behavior to those.
"""
if "anyOf" in obj and "discriminator" in obj:
# This is where the action happens. We've gotten to an object that represents a discriminated union. Now
# we can swap out the `anyOf` key for a `oneOf` key.
obj["oneOf"] = obj.pop("anyOf")
# determine whether or not the object is actually an {allOf: [list of other objects]}
is_all_of_object: bool = "allOf" in obj.keys()
# try to get the "type" of the object
type_ = obj.get("type", None)
if type_ is None and (not is_all_of_object):
# object doesn't have a type, this is a dead end
return
if is_all_of_object:
# When an object is "allOf" then we don't handle the object's properties directly. Rather, we iterate
# through the array of objects that make up the "allOf" and process each of them individually.
all_of_model: Dict[str, Any]
for all_of_model in obj["allOf"]:
_sub_oneof_for_anyof_in_discriminated_unions_recursive_helper(all_of_model)
elif type_ == "object":
# recur through the object's properties to ensure each sub-object is considered
for sub_property in obj.get("properties", {}).values():
_sub_oneof_for_anyof_in_discriminated_unions_recursive_helper(sub_property)
elif type_ == "array":
# For array types, we actually want to operate on the "items" sub-object which describes what the array
# actually contains.
_sub_oneof_for_anyof_in_discriminated_unions_recursive_helper(obj["items"])
else:
# dead end, dealing with a primitive like "string" or "integer"
return
# main.py
openapi_schema = fastapi.openapi.utils.get_openapi(...)
openapi_utils.sub_oneof_for_anyof_in_discriminated_unions(openapi_schema)
I followed up with https://github.com/samuelcolvin/pydantic/pull/4335 for a more long-term fix that would cause Pydantic to generate oneOf
in place of anyOf
.
What's the status of this? Running into the same issue (different code gen tool).
Thanks so much for taking this up @MaxwellPayne! We just upgraded to the new pydantic and now "oneOf" is added to the schema in case we have a discriminated union. This also works perfectly with FastAPI, when we use the model beyond the "top level field" of the endpoint (speaking both for request and response).
To have the discriminated union as a type in "top level field" (request body, response body), I tried to use typing.Annotated to create a intermediate Type which is discriminated (to provide it as a input to FastAPI).
If I use that approach it still provides anyOf instead of oneOf. Not sure if that is a bug or my approach is wrong...
Here is a minimal example (fastapi==0.89.1, pydantic==1.10.4)
from fastapi import FastAPI, status
from typing import Literal, Union, Annotated
from pydantic import BaseModel, Field
# pydantic schemas
class Mammal(BaseModel):
mammaltest: str
class Cat(Mammal):
pet_type: Literal['cat']
meows: int
class Dog(Mammal):
pet_type: Literal['dog']
barks: float
"""This can directly be used as root field for an endpoint"""
MammalBase = Annotated[Union[Dog, Cat], Field(discriminator="pet_type")]
class MammalContainer(BaseModel):
"""Includes a mammal as a field"""
pet: MammalBase
n: int
# FastApi
app = FastAPI()
@app.post(
"/mammal-container-test/",
response_model=MammalContainer,
description="Test endpoint",
status_code=status.HTTP_200_OK,
)
async def mammal_test(mammal_container: MammalContainer):
"""This works perfect both for request and response model and has oneOf"""
return mammal_container
@app.post(
"/mammal-base-test/",
response_model=MammalBase,
description="Test endpoint",
status_code=status.HTTP_200_OK,
)
async def mammal_test(mammal_base: MammalBase):
"""This one still provides anyOf"""
return mammal_base