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
dataand thejsonparameter, which is wrong because you can’t have 2 different request bodies. You either pass indataorjson, but not both. Thedatais typically used for form-encoded inputs from HTML forms, whilejsonis for raw JSON objects. See the requests docs on “More complicated POST requests”. - The requests library will simply drop the
jsonargument because:Note, the
jsonparameter is ignored if eitherdataorfilesis passed. - It is passing-in the plain string
"Baz"to thejsonparameter, which is not a valid JSON object. - The
dataparameter 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
importancein the same body, besides theitemanduser.If you declare it as is, because it is a singular value, FastAPI will assume that it is a
queryparameter.But you can instruct FastAPI to treat it as another body
keyusingBody
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'}