Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

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

Union produces anyOf instead of oneOf for OpenAPI generation #4959

Closed
9 tasks done
stdkoehler opened this issue May 27, 2022 · 5 comments
Closed
9 tasks done

Union produces anyOf instead of oneOf for OpenAPI generation #4959

stdkoehler opened this issue May 27, 2022 · 5 comments

Comments

@stdkoehler
Copy link

stdkoehler commented May 27, 2022

First Check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the FastAPI documentation, with the integrated search.
  • I already searched in Google "How to X in FastAPI" and didn't find any information.
  • I already read and followed all the tutorial in the docs and didn't find an answer.
  • I already checked if it is not related to FastAPI but to Pydantic.
  • I already checked if it is not related to FastAPI but to Swagger UI.
  • I already checked if it is not related to FastAPI but to ReDoc.

Commit to Help

  • 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:
pydantic/pydantic#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

@stdkoehler stdkoehler added the question Question or problem label May 27, 2022
@slackner
Copy link

While this might be a valid issue (I also encountered this bug and am currently using some ugly workarounds 😬), 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.

@MaxwellPayne
Copy link

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)

@MaxwellPayne
Copy link

I followed up with pydantic/pydantic#4335 for a more long-term fix that would cause Pydantic to generate oneOf in place of anyOf.

@robotkutya
Copy link

What's the status of this? Running into the same issue (different code gen tool).

@stdkoehler
Copy link
Author

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

Repository owner locked and limited conversation to collaborators Feb 28, 2023
@tiangolo tiangolo converted this issue into discussion #8504 Feb 28, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Projects
None yet
Development

No branches or pull requests

5 participants