]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
:sparkles: Separate Pydantic's ValidationError handler and improve docs for error...
authorSebastián Ramírez <tiangolo@gmail.com>
Wed, 29 May 2019 12:27:55 +0000 (16:27 +0400)
committerGitHub <noreply@github.com>
Wed, 29 May 2019 12:27:55 +0000 (16:27 +0400)
* :sparkles: Implement separated ValidationError handlers and custom exceptions

* :white_check_mark: Add tutorial source examples and tests

* :memo: Add docs for custom exception handlers

* :memo: Update docs section titles

docs/src/handling_errors/tutorial003.py
docs/src/handling_errors/tutorial004.py [new file with mode: 0644]
docs/src/handling_errors/tutorial005.py [new file with mode: 0644]
docs/tutorial/handling-errors.md
fastapi/applications.py
fastapi/exception_handlers.py [new file with mode: 0644]
fastapi/exceptions.py
fastapi/routing.py
tests/test_tutorial/test_handling_errors/test_tutorial003.py [new file with mode: 0644]
tests/test_tutorial/test_handling_errors/test_tutorial004.py [new file with mode: 0644]
tests/test_tutorial/test_handling_errors/test_tutorial005.py [new file with mode: 0644]

index 8e14305ed91a452ddbb02873696a984f1a18881c..eac9b5cbb76440e72552b971d783be4b2942938e 100644 (file)
@@ -1,15 +1,26 @@
 from fastapi import FastAPI
-from starlette.exceptions import HTTPException
-from starlette.responses import PlainTextResponse
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+
+class UnicornException(Exception):
+    def __init__(self, name: str):
+        self.name = name
+
 
 app = FastAPI()
 
 
-@app.exception_handler(HTTPException)
-async def http_exception(request, exc):
-    return PlainTextResponse(str(exc.detail), status_code=exc.status_code)
+@app.exception_handler(UnicornException)
+async def unicorn_exception_handler(request: Request, exc: UnicornException):
+    return JSONResponse(
+        status_code=418,
+        content={"message": f"Oops! {exc.name} did something. There goes a rainbow..."},
+    )
 
 
-@app.get("/")
-async def root():
-    return {"message": "Hello World"}
+@app.get("/unicorns/{name}")
+async def read_unicorn(name: str):
+    if name == "yolo":
+        raise UnicornException(name=name)
+    return {"unicorn_name": name}
diff --git a/docs/src/handling_errors/tutorial004.py b/docs/src/handling_errors/tutorial004.py
new file mode 100644 (file)
index 0000000..ce25979
--- /dev/null
@@ -0,0 +1,23 @@
+from fastapi import FastAPI, HTTPException
+from fastapi.exceptions import RequestValidationError
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.responses import PlainTextResponse
+
+app = FastAPI()
+
+
+@app.exception_handler(StarletteHTTPException)
+async def http_exception_handler(request, exc):
+    return PlainTextResponse(str(exc.detail), status_code=exc.status_code)
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+    return PlainTextResponse(str(exc), status_code=400)
+
+
+@app.get("/items/{item_id}")
+async def read_item(item_id: int):
+    if item_id == 3:
+        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
+    return {"item_id": item_id}
diff --git a/docs/src/handling_errors/tutorial005.py b/docs/src/handling_errors/tutorial005.py
new file mode 100644 (file)
index 0000000..8cabc9c
--- /dev/null
@@ -0,0 +1,28 @@
+from fastapi import FastAPI, HTTPException
+from fastapi.exception_handlers import (
+    http_exception_handler,
+    request_validation_exception_handler,
+)
+from fastapi.exceptions import RequestValidationError
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+app = FastAPI()
+
+
+@app.exception_handler(StarletteHTTPException)
+async def custom_http_exception_handler(request, exc):
+    print(f"OMG! An HTTP error!: {exc}")
+    return await http_exception_handler(request, exc)
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+    print(f"OMG! The client sent invalid data!: {exc}")
+    return await request_validation_exception_handler(request, exc)
+
+
+@app.get("/items/{item_id}")
+async def read_item(item_id: int):
+    if item_id == 3:
+        raise HTTPException(status_code=418, detail="Nope! I don't like 3.")
+    return {"item_id": item_id}
index d0557c4abd3d27a83749b40c96e8fd3f5346f0c4..becb84fd515f2e1283535a16c87dfa4b7bd12c7c 100644 (file)
@@ -68,7 +68,7 @@ But if the client requests `http://example.com/items/bar` (a non-existent `item_
 
     They are handled automatically by **FastAPI** and converted to JSON.
 
-### Adding custom headers
+## Add custom headers
 
 There are some situations in where it's useful to be able to add custom headers to the HTTP error. For example, for some types of security.
 
@@ -76,24 +76,138 @@ You probably won't need to use it directly in your code.
 
 But in case you needed it for an advanced scenario, you can add custom headers:
 
-
 ```Python hl_lines="14"
 {!./src/handling_errors/tutorial002.py!}
 ```
 
-### Installing custom handlers
+## Install custom exception handlers
+
+You can add custom exception handlers with <a href="https://www.starlette.io/exceptions/" target="_blank">the same exception utilities from Starlette</a>.
 
-If you need to add other custom exception handlers, or override the default one (that sends the errors as JSON), you can use <a href="https://www.starlette.io/exceptions/" target="_blank">the same exception utilities from Starlette</a>.
+Let's say you have a custom exception `UnicornException` that you (or a library you use) might `raise`.
 
-For example, you could override the default exception handler with:
+And you want to handle this exception globally with FastAPI.
 
-```Python hl_lines="2 3 8 9 10"
+You could add a custom exception handler with `@app.exception_handler()`:
+
+```Python hl_lines="6 7 8 14 15 16 17 18 24"
 {!./src/handling_errors/tutorial003.py!}
 ```
 
-...this would make it return "plain text" responses with the errors, instead of JSON responses.
+Here, if you request `/unicorns/yolo`, the *path operation* will `raise` a `UnicornException`.
+
+But it will be handled by the `unicorn_exception_handler`.
+
+So, you will receive a clean error, with an HTTP status code of `418` and a JSON content of:
+
+```JSON
+{"message": "Oops! yolo did something. There goes a rainbow..."}
+```
+
+## Override the default exception handlers
+
+**FastAPI** has some default exception handlers.
+
+These handlers are in charge or returning the default JSON responses when you `raise` an `HTTPException` and when the request has invalid data.
+
+You can override these exception handlers with your own.
+
+### Override request validation exceptions
+
+When a request contains invalid data, **FastAPI** internally raises a `RequestValidationError`.
+
+And it also includes a default exception handler for it.
+
+To override it, import the `RequestValidationError` and use it with `@app.exception_handler(RequestValidationError)` to decorate the exception handler.
+
+The exception handler will receive a `Request` and the exception.
+
+```Python hl_lines="2 14 15 16"
+{!./src/handling_errors/tutorial004.py!}
+```
+
+Now, if you go to `/items/foo`, instead of getting the default JSON error with:
+
+```JSON
+{
+    "detail": [
+        {
+            "loc": [
+                "path",
+                "item_id"
+            ],
+            "msg": "value is not a valid integer",
+            "type": "type_error.integer"
+        }
+    ]
+}
+```
+
+you will get a text version, with:
+
+```
+1 validation error
+path -> item_id
+  value is not a valid integer (type=type_error.integer)
+```
+
+#### `RequestValidationError` vs `ValidationError`
+
+!!! warning
+    These are technical details that you might skip if it's not important for you now.
+
+`RequestValidationError` is a sub-class of Pydantic's <a href="https://pydantic-docs.helpmanual.io/#error-handling" target="_blank">`ValidationError`</a>.
+
+**FastAPI** uses it so that, if you use a Pydantic model in `response_model`, and your data has an error, you will see the error in your log.
+
+But the client/user will not see it. Instead, the client will receive an "Internal Server Error" with a HTTP status code `500`.
+
+It should be this way because if you have a Pydantic `ValidationError` in your *response* or anywhere in your code (not in the client's *request*), it's actually a bug in your code.
+
+And while you fix it, your clients/users shouldn't have access to internal information about the error, as that could expose a security vulnerability.
+
+### Override the `HTTPException` error handler
+
+The same way, you can override the `HTTPException` handler.
+
+For example, you could want to return a plain text response instead of JSON for these errors:
+
+```Python hl_lines="1 3 9 10 11 22"
+{!./src/handling_errors/tutorial004.py!}
+```
+
+#### FastAPI's `HTTPException` vs Starlette's `HTTPException`
+
+**FastAPI** has its own `HTTPException`.
+
+And **FastAPI**'s `HTTPException` error class inherits from Starlette's `HTTPException` error class.
+
+The only difference, is that **FastAPI**'s `HTTPException` allows you to add headers to be included in the response.
+
+This is needed/used internally for OAuth 2.0 and some security utilities.
+
+So, you can keep raising **FastAPI**'s `HTTPException` as normally in your code.
+
+But when you register an exception handler, you should register it for Starlette's `HTTPException`.
+
+This way, if any part of Starlette's internal code, or a Starlette extension or plug-in, raises an `HTTPException`, your handler will be able to catch handle it.
+
+In this example, to be able to have both `HTTPException`s in the same code, Starlette's exceptions is renamed to `StarletteHTTPException`:
+
+```Python
+from starlette.exceptions import HTTPException as StarletteHTTPException
+```
+
+### Re-use **FastAPI**'s exception handlers
+
+You could also just want to use the exception somehow, but then use the same default exception handlers from **FastAPI**.
+
+You can import and re-use the default exception handlers from `fastapi.exception_handlers`:
+
+```Python hl_lines="2 3 4 5 15 21"
+{!./src/handling_errors/tutorial005.py!}
+```
 
-!!! info
-    Note that in this example we set the exception handler with Starlette's `HTTPException` instead of FastAPI's `HTTPException`.
+In this example, you are just `print`ing the error with a very expressive notification.
 
-    This would ensure that if you use a plug-in or any other third-party tool that raises Starlette's `HTTPException` directly, it will be caught by your exception handler.
+But you get the idea, you can use the exception and then just re-use the default exception handlers.
index 6917c6a9efbc649c38bcd4a959fb5218cd7156ff..3c38b9d24a95f616ebb322e0c300d77824e19465 100644 (file)
@@ -1,6 +1,11 @@
 from typing import Any, Callable, Dict, List, Optional, Set, Type, Union
 
 from fastapi import routing
+from fastapi.exception_handlers import (
+    http_exception_handler,
+    request_validation_exception_handler,
+)
+from fastapi.exceptions import RequestValidationError
 from fastapi.openapi.docs import (
     get_redoc_html,
     get_swagger_ui_html,
@@ -8,7 +13,6 @@ from fastapi.openapi.docs import (
 )
 from fastapi.openapi.utils import get_openapi
 from fastapi.params import Depends
-from pydantic import BaseModel
 from starlette.applications import Starlette
 from starlette.exceptions import ExceptionMiddleware, HTTPException
 from starlette.middleware.errors import ServerErrorMiddleware
@@ -17,16 +21,6 @@ from starlette.responses import HTMLResponse, JSONResponse, Response
 from starlette.routing import BaseRoute
 
 
-async def http_exception(request: Request, exc: HTTPException) -> JSONResponse:
-    headers = getattr(exc, "headers", None)
-    if headers:
-        return JSONResponse(
-            {"detail": exc.detail}, status_code=exc.status_code, headers=headers
-        )
-    else:
-        return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
-
-
 class FastAPI(Starlette):
     def __init__(
         self,
@@ -120,7 +114,10 @@ class FastAPI(Starlette):
                 )
 
             self.add_route(self.redoc_url, redoc_html, include_in_schema=False)
-        self.add_exception_handler(HTTPException, http_exception)
+        self.add_exception_handler(HTTPException, http_exception_handler)
+        self.add_exception_handler(
+            RequestValidationError, request_validation_exception_handler
+        )
 
     def add_api_route(
         self,
diff --git a/fastapi/exception_handlers.py b/fastapi/exception_handlers.py
new file mode 100644 (file)
index 0000000..cda2f8c
--- /dev/null
@@ -0,0 +1,23 @@
+from fastapi.exceptions import RequestValidationError
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
+
+
+async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
+    headers = getattr(exc, "headers", None)
+    if headers:
+        return JSONResponse(
+            {"detail": exc.detail}, status_code=exc.status_code, headers=headers
+        )
+    else:
+        return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
+
+
+async def request_validation_exception_handler(
+    request: Request, exc: RequestValidationError
+) -> JSONResponse:
+    return JSONResponse(
+        status_code=HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": exc.errors()}
+    )
index aed00150bea421010f7815d3b607bc6b8e8352aa..97f955a73156ed82cf9537d3f913e2ae4defe190 100644 (file)
@@ -1,3 +1,4 @@
+from pydantic import ValidationError
 from starlette.exceptions import HTTPException as StarletteHTTPException
 
 
@@ -7,3 +8,11 @@ class HTTPException(StarletteHTTPException):
     ) -> None:
         super().__init__(status_code=status_code, detail=detail)
         self.headers = headers
+
+
+class RequestValidationError(ValidationError):
+    pass
+
+
+class WebSocketRequestValidationError(ValidationError):
+    pass
index b35a5f45d15e769b4c557e3c42f8233af5b379c0..0071946525ca5edda14e321976e533374cf6d6cf 100644 (file)
@@ -13,6 +13,7 @@ from fastapi.dependencies.utils import (
     solve_dependencies,
 )
 from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError
 from pydantic import BaseConfig, BaseModel, Schema
 from pydantic.error_wrappers import ErrorWrapper, ValidationError
 from pydantic.fields import Field
@@ -28,7 +29,7 @@ from starlette.routing import (
     request_response,
     websocket_session,
 )
-from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, WS_1008_POLICY_VIOLATION
+from starlette.status import WS_1008_POLICY_VIOLATION
 from starlette.websockets import WebSocket
 
 
@@ -103,10 +104,7 @@ def get_app(
             request=request, dependant=dependant, body=body
         )
         if errors:
-            errors_out = ValidationError(errors)
-            raise HTTPException(
-                status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=errors_out.errors()
-            )
+            raise RequestValidationError(errors)
         else:
             assert dependant.call is not None, "dependant.call must be a function"
             if is_coroutine:
@@ -141,10 +139,7 @@ def get_websocket_app(dependant: Dependant) -> Callable:
         )
         if errors:
             await websocket.close(code=WS_1008_POLICY_VIOLATION)
-            errors_out = ValidationError(errors)
-            raise HTTPException(
-                status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail=errors_out.errors()
-            )
+            raise WebSocketRequestValidationError(errors)
         assert dependant.call is not None, "dependant.call must me a function"
         await dependant.call(**values)
 
diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial003.py b/tests/test_tutorial/test_handling_errors/test_tutorial003.py
new file mode 100644 (file)
index 0000000..0ead07d
--- /dev/null
@@ -0,0 +1,91 @@
+from starlette.testclient import TestClient
+
+from handling_errors.tutorial003 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "Fast API", "version": "0.1.0"},
+    "paths": {
+        "/unicorns/{name}": {
+            "get": {
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+                "summary": "Read Unicorn",
+                "operationId": "read_unicorn_unicorns__name__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Name", "type": "string"},
+                        "name": "name",
+                        "in": "path",
+                    }
+                ],
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_get():
+    response = client.get("/unicorns/shinny")
+    assert response.status_code == 200
+    assert response.json() == {"unicorn_name": "shinny"}
+
+
+def test_get_exception():
+    response = client.get("/unicorns/yolo")
+    assert response.status_code == 418
+    assert response.json() == {
+        "message": "Oops! yolo did something. There goes a rainbow..."
+    }
diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial004.py b/tests/test_tutorial/test_handling_errors/test_tutorial004.py
new file mode 100644 (file)
index 0000000..09ccd46
--- /dev/null
@@ -0,0 +1,100 @@
+from starlette.testclient import TestClient
+
+from handling_errors.tutorial004 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "Fast API", "version": "0.1.0"},
+    "paths": {
+        "/items/{item_id}": {
+            "get": {
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+                "summary": "Read Item",
+                "operationId": "read_item_items__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "integer"},
+                        "name": "item_id",
+                        "in": "path",
+                    }
+                ],
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_get_validation_error():
+    response = client.get("/items/foo")
+    assert response.status_code == 400
+    validation_error_str_lines = [
+        b"1 validation error",
+        b"path -> item_id",
+        b"  value is not a valid integer (type=type_error.integer)",
+    ]
+    assert response.content == b"\n".join(validation_error_str_lines)
+
+
+def test_get_http_error():
+    response = client.get("/items/3")
+    assert response.status_code == 418
+    assert response.content == b"Nope! I don't like 3."
+
+
+def test_get():
+    response = client.get("/items/2")
+    assert response.status_code == 200
+    assert response.json() == {"item_id": 2}
diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial005.py b/tests/test_tutorial/test_handling_errors/test_tutorial005.py
new file mode 100644 (file)
index 0000000..a59399a
--- /dev/null
@@ -0,0 +1,103 @@
+from starlette.testclient import TestClient
+
+from handling_errors.tutorial005 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "Fast API", "version": "0.1.0"},
+    "paths": {
+        "/items/{item_id}": {
+            "get": {
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+                "summary": "Read Item",
+                "operationId": "read_item_items__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "integer"},
+                        "name": "item_id",
+                        "in": "path",
+                    }
+                ],
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi_schema():
+    response = client.get("/openapi.json")
+    assert response.status_code == 200
+    assert response.json() == openapi_schema
+
+
+def test_get_validation_error():
+    response = client.get("/items/foo")
+    assert response.status_code == 422
+    assert response.json() == {
+        "detail": [
+            {
+                "loc": ["path", "item_id"],
+                "msg": "value is not a valid integer",
+                "type": "type_error.integer",
+            }
+        ]
+    }
+
+
+def test_get_http_error():
+    response = client.get("/items/3")
+    assert response.status_code == 418
+    assert response.json() == {"detail": "Nope! I don't like 3."}
+
+
+def test_get():
+    response = client.get("/items/2")
+    assert response.status_code == 200
+    assert response.json() == {"item_id": 2}