When working with FastAPI and Pydantic, you may occasionally find that the standard validation error messages don't quite align with your requirements. To better illustrate this point, let's consider a straightforward example involving a route designed for updating a post:
@app.put("/{post_id}", response_model=schemas.PostResponse)
def update_post(
post_id: int,
post: schemas.PostUpdate,
db: Session = Depends(get_db),
current_user: schemas.User = Depends(oauth2.get_current_user),
):
db_post = get_post_or_404(db, post_id)
# Check if the user is the owner of the post
if is_post_owner(db_post, current_user):
# code for updating
- In this setup, the route receives a
post_idas a URL parameter and uses it to retrieve the corresponding post from the database. - The update process is initiated only if the post exists and the logged-in user is its author. However, the focus here extends beyond just the
post_id. - The route also anticipates receiving a
postobject. This object conforms to aPostUpdateschema, which we've defined separately in another file. You can disregard the details concerningdbandcurrent_userfor this discussion. They're not crucial to the aspect we're focusing on. - Say the
PostUpdateschema looks something like this:
class PostUpdate(BaseModel):
title: str
content: str
published: bool = False
rating: Optional[int] = None
- The core expectation here is to receive at least a
titleandcontent. For the fieldspublishedandrating, the system is designed to use default values if they are not explicitly provided, ensuring flexibility and robustness in data handling. - So, what if we hit the endpoint and send a title that isn’t a string? What would happen?
curl -i --request PUT \
--url http://localhost:8000/posts/38 \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiaGkgdGhlcmUsIGN1cmlvdXMgZnJpZW5kIiwiZXhwIjoxNzA1ODUyMDM3fQ.qpNy5poAYReVIcacP8GxRKW8DoMlVqs6ljoIq9QNYOw' \
--header 'Content-Type: application/json' \
--data '{
"title": 25,
"content": "this is the updated content"
}'
HTTP/1.1 422 Unprocessable Entity
date: Mon, 01 Jan 2024 08:42:27 GMT
server: uvicorn
content-length: 162
content-type: application/json
{
"detail": [
{
"type": "string_type",
"loc": [
"body",
"title"
],
"msg": "Input should be a valid string",
"input": 25,
"url": "https://errors.pydantic.dev/2.5/v/string_type"
}
]
}
- As you can see, we got a response detail that looks something like this:
{
"detail": [
{
"type": "string_type",
"loc": [
"body",
"title"
],
"msg": "Input should be a valid string",
"input": 25,
"url": "https://errors.pydantic.dev/2.5/v/string_type"
}
]
}
- It's good and informative, but that's not quite what we want. Let's add some customization to the validation error response.
{
"status": "validation_error",
"detail": "Input validation failed 😕",
"errors": [
{
"field": "body -> title",
"message": "Input should be a valid string 🚨",
"error_type": "string_type",
"suggestion": "Check this field for errors. 🛠️"
}
],
"tip": "Please review the errors and try again! 👀",
"custom_docs": "http://localhost:8000/docs/posts/38",
"custom_support": "555-I-WONT-PICK-UP"
}
- To accomplish this, we can customize the
RequestValidationErrorfrom FastAPI. This modification can be implemented in the section of your code where your app is defined. Here's how you can do it:
from fastapi.exceptions import RequestValidationError
from fastapi.requests import Request
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = exc.errors()
formatted_errors = []
def generate_helpful_message(error):
# Custom logic to generate a helpful message based on error type/location
if error["type"] == "type_error.integer":
return "Ensure this value is an integer. 🔢"
elif error["type"] == "value_error.missing":
return "This field is required and cannot be missing. ❗"
# Add more cases as needed
return "Check this field for errors. 🛠️"
for error in errors:
field = " -> ".join(map(str, error["loc"]))
message = error["msg"]
helpful_message = generate_helpful_message(error)
pretty_message = {
"field": field,
"message": f"{message} 🚨",
"error_type": error["type"],
"suggestion": helpful_message,
}
formatted_errors.append(pretty_message)
# Generate a link to the docs for this endpoint
docs_url = (
f"{request.base_url.scheme}://{request.base_url.netloc}/docs{request.url.path}"
)
return JSONResponse(
status_code=422,
content={
"status": "validation_error",
"detail": "Input validation failed 😕",
"errors": formatted_errors,
"tip": "Please review the errors and try again! 👀",
"custom_docs": f"{docs_url}", # Link to your API documentation
"custom_support": "555-I-WONT-PICK-UP", # Link to your support page
},
)
Let’s try it now.
curl -i --request PUT \
--url http://localhost:8000/posts/38 \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0LCJleHAiOjE3MDU4NTIwMzd9.TI6j0t8wBOrAz_00NDI-FdtSjtMqtlv3w-Gl2ZUwz9c' \
--header 'Content-Type: application/json' \
--header 'User-Agent: insomnia/8.4.5' \
--data '{
"title": 25,
"content": "this is the content"
}'
HTTP/1.1 422 Unprocessable Entity
date: Mon, 01 Jan 2024 09:20:40 GMT
server: uvicorn
content-length: 376
content-type: application/json
{
"status": "validation_error",
"detail": "Input validation failed 😕",
"errors": [
{
"field": "body -> title",
"message": "Input should be a valid string 🚨",
"error_type": "string_type",
"suggestion": "Check this field for errors. 🛠️"
}
],
"tip": "Please review the errors and try again! 👀",
"custom_docs": "http://localhost:8000/docs/posts/38",
"custom_support": "555-I-WONT-PICK-UP"
}
- Notice how the new validation error is much more informative? While using emojis in responses might not be conventional or recommended due to compatibility and parsing issues, they certainly add a visually appealing element. Plus, if you have custom documentation, you can link to it directly, offering users a way to troubleshoot issues. And as a last resort, you can even add a custom support line in the response for additional help.
🎉
