fastapi icon indicating copy to clipboard operation
fastapi copied to clipboard

Validation of response model when using discriminated union

Open pierreedouardchaix opened this issue 1 year ago • 1 comments

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 enum import Enum
from typing import Annotated, Union, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field, root_validator


class PetType(str, Enum):
	CAT = "cat"
	DOG = "dog"

class Cat(BaseModel):
	kind: Literal[PetType.CAT]
	has_paws: bool
	color: str

	@root_validator
	def validate_cat(cls, values):
		print("Validating Cat")
		color = values.get('color')
		if color.lower() != "yellow":
			raise ValueError("Cat must be yellow")
		return values

class Dog(BaseModel):
	kind: Literal[PetType.DOG]
	has_paws: bool

	@root_validator
	def validate_cat(cls, values):
		print("Validating Dog")
		return values

Pet = Annotated[Union[Cat, Dog], Field(discriminator='kind')]

class Animal(BaseModel):
	pet: Pet

app = FastAPI()


@app.post("/", response_model=Animal)
def handle_pet(animal: Animal):
	print(f"Pet has been discriminated: {type(animal.pet)}")
	print(f"Animal can be re-created from dict: {Animal(**animal.dict())}")
	return animal

Description

The POST / endpoint can be used normally with a pet Cat payload:

{
	"pet": {
		"kind": "cat",
		"has_paws": true,
		"color": "Yellow"
	}
}

However, using a pet Dog payload:

{
	"pet": {
		"kind": "dog",
		"has_paws": true
	}
}

an error is raised, because the root_validator function of the Cat class is called. This happens when the endpoint function returns. When validating the input payload, only the Dog root validator is called.

I know that this issue will likely also be linked to pydantic and discriminated unions; but why is FastAPI trying to validate the response against the Cat model? As noted, re-creating the Animal object from the object .dict() does not fail.

Operating System

macOS

Operating System Details

No response

FastAPI Version

0.79.0

Python Version

3.9.12

Additional Context

No response

pierreedouardchaix avatar Aug 04 '22 15:08 pierreedouardchaix

# routing.py ln128
value, errors_ = field.validate(response_content, {}, loc=("response",))
        else:
            value, errors_ = await run_in_threadpool(  # type: ignore[misc]
                field.validate, response_content, {}, loc=("response",)
            )

It fails at this point! I am not sure if the issue is with Pydantic or FastAPI. It seems to call the validate method on ModelField class, which is not handling the discrimination properly; and all this are happeing on Pydantic side.

Discussions related: https://github.com/samuelcolvin/pydantic/issues/3758

From above reference, we can get a better understanding.

Since response model are created with Optional it is no considering the discriminator.

To replicate, I tried this:

class Animal(BaseModel, ):
    pet: Optional[Annotated[Union[Cat, Dog], Field(discriminator='kind')]]



Animal.validate({
	"pet": {
		"kind": "dog",
		"has_paws": True
	}
})

iudeen avatar Aug 04 '22 18:08 iudeen