I have a file called main.py
as follows:
from typing import Optional from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() fake_db = { "Foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"}, "Bar": {"id": "bar", "title": "Bar", "description": "The bartenders"}, } class Item(BaseModel): id: str title: str description: Optional[str] = None @app.post("/items/", response_model=Item) async def create_item(item: Item, key: str): fake_db[key] = item return item
Now, if I run the code for the test, saved in the file test_main.py
from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_create_item(): response = client.post( "/items/", {"id": "baz", "title": "A test title", "description": "A test description"}, "Baz" ) return response.json() print(test_create_item())
I don’t get the desired result, that is
{"id": "baz", "title": "A test title", "description": "A test description"}
What is the mistake?
Advertisement
Answer
Let’s start by explaining what you are doing wrong.
FastAPI’s TestClient
is just a re-export of Starlette’s TestClient
which is a subclass of requests.Session. The requests library’s post
method has the following signature:
def post(self, url, data=None, json=None, **kwargs): r"""Sends a POST request. Returns :class:`Response` object. :param url: URL for the new :class:`Request` object. :param data: (optional) Dictionary, list of tuples, bytes, or file-like object to send in the body of the :class:`Request`. :param json: (optional) json to send in the body of the :class:`Request`. :param **kwargs: Optional arguments that ``request`` takes. :rtype: requests.Response """
Your code
response = client.post( "/items/", {"id": "baz", "title": "A test title", "description": "A test description"}, "Baz", )
is doing multiple things wrong:
- It is passing in arguments to both the
data
and thejson
parameter, which is wrong because you can’t have 2 different request bodies. You either pass indata
orjson
, but not both. Thedata
is typically used for form-encoded inputs from HTML forms, whilejson
is for raw JSON objects. See the requests docs on “More complicated POST requests”. - The requests library will simply drop the
json
argument because:Note, the
json
parameter is ignored if eitherdata
orfiles
is passed. - It is passing-in the plain string
"Baz"
to thejson
parameter, which is not a valid JSON object. - The
data
parameter expects form-encoded data.
The full error returned by FastAPI in the response is:
def test_create_item(): response = client.post( "/items/", {"id": "baz", "title": "A test title", "description": "A test description"}, "Baz" ) print(response.status_code) print(response.reason) return response.json() # 422 Unprocessable Entity # {'detail': [{'loc': ['query', 'key'], # 'msg': 'field required', # 'type': 'value_error.missing'}, # {'loc': ['body'], # 'msg': 'value is not a valid dict', # 'type': 'type_error.dict'}]}
The 1st error says key
is missing from the query, meaning the route parameter key
value "Baz"
was not in the request body and FastAPI tried to look for it from the query parameters (see the FastAPI docs on Request body + path + query parameters).
The 2nd error is from point #4 I listed above about data
not being properly form-encoded (that error does go away when you wrap the dict value in json.dumps
, but that’s not important nor is it part of the solution).
You said in a comment that you were trying to do the same thing as in the FastAPI Testing Tutorial. The difference of that tutorial from your code is that was POSTing all the attributes of the Item
object in 1 body, and that it was using the json=
parameter of .post
.
Now on the solutions!
Solution #1: Have a separate class for POSTing the item attributes with a key
Here, you’ll need 2 classes, one with a key
attribute that you use for the POST request body (let’s call it NewItem
), and your current one Item
for the internal DB and for the response model. Your route function will then have just 1 parameter (new_item
) and you can just get the key
from that object.
main.py
class Item(BaseModel): id: str title: str description: Optional[str] = None class NewItem(Item): key: str @app.post("/items/", response_model=Item) async def create_item(new_item: NewItem): # See Pydantic https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeldict # Also, Pydantic by default will ignore the extra attribute `key` when creating `Item` item = Item(**new_item.dict()) print(item) fake_db[new_item.key] = item return item
For the test .post
code, use json=
to pass all the fields in 1 dictionary.
test_main.py
def test_create_item(): response = client.post( "/items/", json={ "key": "Baz", "id": "baz", "title": "A test title", "description": "A test description", }, ) print(response.status_code, response.reason) return response.json()
Output
id='baz' title='A test title' description='A test description' 200 OK {'description': 'A test description', 'id': 'baz', 'title': 'A test title'}
Solution #2: Have 2 body parts, 1 for the item attributes and 1 for the key
You can structure the POSTed body like this instead:
{ "item": { "id": "baz", "title": "A test title", "description": "A test description", }, "key": "Baz", },
where you have the Item
attributes in a nested dict and then have a simple key
-value pair in the same level as item
. FastAPI can handle this, see the docs on Singular values in body, which fits your example quite nicely:
For example, extending the previous model, you could decide that you want to have another key
importance
in the same body, besides theitem
anduser
.If you declare it as is, because it is a singular value, FastAPI will assume that it is a
query
parameter.But you can instruct FastAPI to treat it as another body
key
usingBody
Note the parts I emphasized, about telling FastAPI to look for key
in the same body. It is important here that the parameter names item
and key
match the ones in the request body.
main.py
from fastapi import Body, FastAPI class Item(BaseModel): id: str title: str description: Optional[str] = None @app.post("/items/", response_model=Item) async def create_item(item: Item, key: str = Body(...)): print(item) fake_db[key] = item return item
Again, for making the .post
request, use json=
to pass the entire dictionary.
test_main.py
def test_create_item(): response = client.post( "/items/", json={ "item": { "id": "baz", "title": "A test title", "description": "A test description", }, "key": "Baz", }, ) print(response.status_code, response.reason) return response.json()
Output
id='baz' title='A test title' description='A test description' 200 OK {'description': 'A test description', 'id': 'baz', 'title': 'A test title'}