Fastapi: [BUG] Empty validation error when request parameters are of Union type

Created on 9 Jun 2019  路  12Comments  路  Source: tiangolo/fastapi

Describe the bug

The Union type works as expected when response model is defined as such according to docs: https://fastapi.tiangolo.com/tutorial/extra-models/#union-or-anyof However when parameters (body payload) are defined as Union the code runs fine until executing method, and docs are picking up the types and generating schema correctly but the empty ValidationRequestError is thrown when route method is called even if there are all parameters sent are valid.

To Reproduce

Sample code to reproduce:

class SimpleData(BaseModel):
    foo: Optional[str] = None

class ExtendedData(SimpleData):
    bar: str # Note that this is required

PostData = Union[ExtendedData, SimpleData]

@router.post("/test")
async def post(data: PostData):
    return "OK"

Then the POST /test route is called with a body payload:

{
    "foo": "test1",
    "bar": "test2"
}

As a result the empty ValidationRequestError is thrown with value_error.missing message but no actual field assigned to it.

Expected behavior
Parameters from the request are resolved and parsed against the types inside Union.

Environment:

  • FastAPI Version: 0.29.0
bug

All 12 comments

just ran quickly this and it passes fine so I may have misunderstood what goes wrong ?

from typing import Optional, Union

from pydantic import BaseModel
from starlette.testclient import TestClient

from fastapi import FastAPI


class SimpleData(BaseModel):
    foo: Optional[str] = None


class ExtendedData(SimpleData):
    bar: str # Note that this is required


PostData = Union[ExtendedData, SimpleData]


app = FastAPI()
client = TestClient(app)
@app.post("/testunion")
async def testunion(data: PostData):
    print(data)
    return "ok"


def test_union():
    data = PostData(foo="test1", bar="test2")
    response = client.post("/testunion", json=data.dict())
    assert response.status_code == 200
    assert response.json() == "ok"
    data = {"foo": "test1", "bar": "test2"}
    response = client.post("/testunion", json=data)
    assert response.status_code == 200
    assert response.json() == "ok"


I'm following exactly the same pattern in my code and it doesn't work. Error is somehow thrown when request is parsed.

My models look like this:

from pydantic import BaseModel, condecimal, Decimal
from datetime import datetime

class FetcherPatchSimpleData(BaseModel):
    retries: condecimal(gt=Decimal(0)) = None
    start_date: Optional[datetime] = None
    end_date: Optional[datetime] = None

class FetcherPatchAdminData(FetcherPatchSimpleData):
    status: Optional[str] = None
    active: Optional[bool] = None


FetcherPatchData = Union[FetcherPatchAdminData, FetcherPatchSimpleData]

Then as for route:

@app.patch("/fetchers/{fetcher_id}")
async def patch(*,
                fetcher_id: UUID,
                data: FetcherPatchData):
    # some app logic (never gets here)
    return "OK"

As a result exception is thrown:

Image 2019-06-09 at 7 08 00 PM

If I change data: FetcherPatchData to data: FetcherPatchSimpleData or data: FetcherPatchAdminData everything works fine.

mmm this passes fine as well, just changed a patch to a post and removed the uuid
Would you share an example of what you actually pass as FetcherPatchData object ?

I had to use json.loads(model.json()) instead of model.dict() in the tests because of Decimal and datetime json serialization, maybe you're hit by that before you think ?

import json
from datetime import datetime
from typing import Optional, Union

from pydantic import BaseModel, condecimal, Decimal, ConstrainedDecimal, conint
from starlette.testclient import TestClient

from fastapi import FastAPI


class SimpleData(BaseModel):
    foo: Optional[str] = None


class ExtendedData(SimpleData):
    bar: str # Note that this is required


PostData = Union[ExtendedData, SimpleData]


class FetcherPatchSimpleData(BaseModel):
    retries: condecimal(gt=0) = None
    start_date: Optional[datetime] = None
    end_date: Optional[datetime] = None


class FetcherPatchAdminData(FetcherPatchSimpleData):
    status: Optional[str] = None
    active: Optional[bool] = None


FetcherPatchData = Union[FetcherPatchAdminData, FetcherPatchSimpleData]


app = FastAPI()
client = TestClient(app)


@app.post("/testunion")
async def testunion(data: PostData):
    print(data)
    return "ok"


@app.post("/fetchers")
async def test_fetchers(data: FetcherPatchData):
    # some app logic (never gets here)
    return "OK"


def test_union():
    data = PostData(foo="test1", bar="test2")
    response = client.post("/testunion", json=data.dict())
    assert response.status_code == 200
    assert response.json() == "ok"
    data = {"foo": "test1", "bar": "test2"}
    response = client.post("/testunion", json=data)
    assert response.status_code == 200
    assert response.json() == "ok"


def test_fetchers():
    fpad = FetcherPatchAdminData(status="s1", active=True)
    response = client.post("/fetchers", json=fpad.dict())
    assert response.status_code == 200
    assert response.json() == "OK"

    start_date = datetime(2018,12,2)
    end_date = datetime(2019,1,2)
    fpsd = FetcherPatchSimpleData(retries=11, start_date=start_date, end_date=end_date)
    response = client.post("/fetchers", json=json.loads(fpsd.json()))
    assert response.status_code == 200
    assert response.json() == "OK"

I tried to change my code, but nothing works like that and I' still getting the error in any case. As a workaround I could force resolving Union by providing source of parameters explicitly:

@app.patch("/fetchers")
async def test_fetchers(data: FetcherPatchData = Body(...)):
    # some app logic (never gets here)
    return "OK"

Only then Union is parsed correctly, otherwise FastAPI looks up the data parameter in query string parameters it seems. For unknown reason it works fine if the type is not an Union but for example FetcherPatchSimpleData. Not exactly how it should be, right?

Would you share an example of what you actually pass as FetcherPatchData object ?

I send just a json payload, for example:

{
   "retries": 3,
   "active": true
}

@LKay please write a simple self-contained app that doesn't pass and shows your error. Something that can be run as is.

That way we would be able to check it and help you debug it or discover the issue.

I made a self-contained (modified) version of your original example and seems to be working fine:

from typing import Union, Optional
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class SimpleData(BaseModel):
    foo: Optional[str] = None


class ExtendedData(SimpleData):
    bar: str  # Note that this is required


PostData = Union[ExtendedData, SimpleData]


@app.post("/test")
async def post(data: PostData):
    return "OK"

Also, note that there have been frequent releases with new features and bug fixes. So, it might be that you have an older version of FastAPI and that the error might disappear once you update.

I've encountered the same issue when trying to post to an endpoint meant to handle unions. Modified @tiangolo's example above slightly:

#!/usr/bin/env python3.7

import logging
import sys
from typing import Union, Optional

import fastapi
from fastapi import FastAPI
from pydantic import BaseModel
from starlette.testclient import TestClient


handler = logging.StreamHandler(sys.stdout)
logger = logging.getLogger('fastapi')
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)

logger.debug(f'FastAPI v{fastapi.__version__}')

app = FastAPI()
app.debug = True

client = TestClient(app)


class SimpleData(BaseModel):
    foo: Optional[str] = None


class ExtendedData(SimpleData):
    bar: str  # Note that this is required


PostData = Union[ExtendedData, SimpleData]


@app.post("/test")
def post(data: PostData):
    logger.debug("Never reaches here.")
    return "OK"


def test_union():
    data = ExtendedData(bar='test1', foo='test2')
    response = client.post("/test", json=data.dict())
    logger.debug(response)
    logger.debug(response.reason)
    logger.debug(response.text)

test_union()

Logging output:

FastAPI v0.30.0
<Response [422]>
Unprocessable Entity
{"detail":[{"loc":["query","data"],"msg":"field required","type":"value_error.missing"}]}

I did expect to get more debug logging - am I missing something when setting up logging?

@nckswt that's interesting, FastAPI is thinking that the data is a query parameter. I think that's a FastAPI bug on its own. But meanwhile, you can force FastAPI to think it's a body by using Body(...).

Interesting! Yup, can confirm that

@app.post("/test")
def post(data: PostData = Body(...)):
    return "OK"

Does return successfully.

I just created The PR which fix this issue.

This was fixed by @koxudaxi in #400. :tada: :rocket:

Something to have in mind, when using Union in Python 3.6:

If you have a model ExtendedData that inherits from SimpleData, and a type Union[ExtendedData, SimpleData], Python 3.6 will convert that type to just SimpleData.

To use a Union with a model that inherits from another you need Python 3.7.

This seems to be fixed, so closing to clean up backlog.

Was this page helpful?
0 / 5 - 0 ratings