]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
:sparkles: Add HTTPException with custom headers (#35)
authorSebastián Ramírez <tiangolo@gmail.com>
Sat, 16 Feb 2019 13:01:29 +0000 (17:01 +0400)
committerGitHub <noreply@github.com>
Sat, 16 Feb 2019 13:01:29 +0000 (17:01 +0400)
* :memo: Update Release Notes with issue templates

* :sparkles: Add HTTPException with support for headers

Including docs and tests

* :memo: Update Security docs to use new HTTPException

16 files changed:
docs/release-notes.md
docs/src/handling_errors/tutorial001.py [new file with mode: 0644]
docs/src/handling_errors/tutorial002.py [new file with mode: 0644]
docs/src/security/tutorial003.py
docs/src/security/tutorial004.py
docs/tutorial/handling-errors.md [new file with mode: 0644]
docs/tutorial/security/oauth2-jwt.md
docs/tutorial/security/simple-oauth2.md
fastapi/__init__.py
fastapi/applications.py
fastapi/exceptions.py [new file with mode: 0644]
mkdocs.yml
tests/test_starlette_exception.py [new file with mode: 0644]
tests/test_tutorial/test_handling_errors/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_handling_errors/test_tutorial001.py [new file with mode: 0644]
tests/test_tutorial/test_handling_errors/test_tutorial002.py [new file with mode: 0644]

index eb3fc32e2420284bd5036a0e4987bf2a631ee5fa..79a61bdc0c77e0e7e90bf306b220a849e3761054 100644 (file)
@@ -2,6 +2,8 @@
 
 * Add <a href="https://fastapi.tiangolo.com/tutorial/using-request-directly/" target="_blank">documentation to use Starlette `Request` object</a> directly. Check <a href="https://github.com/tiangolo/fastapi/pull/25" target="_blank">#25</a> by <a href="https://github.com/euri10" target="_blank">@euri10</a>
 
+* Add issue templates to simplify reporting bugs, getting help, etc: <a href="https://github.com/tiangolo/fastapi/pull/34" target="_blank">#34</a>
+
 * Update example for the SQLAlchemy tutorial at <a href="https://fastapi.tiangolo.com/tutorial/sql-databases/" target="_blank">https://fastapi.tiangolo.com/tutorial/sql-databases/</a> using middleware and database session attached to request.
 
 ## 0.4.0
diff --git a/docs/src/handling_errors/tutorial001.py b/docs/src/handling_errors/tutorial001.py
new file mode 100644 (file)
index 0000000..8d02940
--- /dev/null
@@ -0,0 +1,12 @@
+from fastapi import FastAPI, HTTPException
+
+app = FastAPI()
+
+items = {"foo": "The Foo Wrestlers"}
+
+
+@app.get("/items/{item_id}")
+async def create_item(item_id: str):
+    if item_id not in items:
+        raise HTTPException(status_code=404, detail="Item not found")
+    return {"item": items[item_id]}
diff --git a/docs/src/handling_errors/tutorial002.py b/docs/src/handling_errors/tutorial002.py
new file mode 100644 (file)
index 0000000..bbc3853
--- /dev/null
@@ -0,0 +1,16 @@
+from fastapi import FastAPI, HTTPException
+
+app = FastAPI()
+
+items = {"foo": "The Foo Wrestlers"}
+
+
+@app.get("/items-header/{item_id}")
+async def create_item_header(item_id: str):
+    if item_id not in items:
+        raise HTTPException(
+            status_code=404,
+            detail="Item not found",
+            headers={"X-Error": "There goes my error"},
+        )
+    return {"item": items[item_id]}
index 32db02a074b02510210f377ac979691ce0be596f..2e736c26018bc99494f6ab5c1832c4e397571feb 100644 (file)
@@ -1,7 +1,6 @@
-from fastapi import Depends, FastAPI, Security
+from fastapi import Depends, FastAPI, HTTPException, Security
 from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
 from pydantic import BaseModel
-from starlette.exceptions import HTTPException
 
 fake_users_db = {
     "johndoe": {
index d4b5bb3e24716952c84b552c50a0095f132c6186..31895c5cb2d0348fd3391169f560fd3f8e8103c8 100644 (file)
@@ -1,12 +1,11 @@
 from datetime import datetime, timedelta
 
 import jwt
-from fastapi import Depends, FastAPI, Security
+from fastapi import Depends, FastAPI, Security, HTTPException
 from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
 from jwt import PyJWTError
 from passlib.context import CryptContext
 from pydantic import BaseModel
-from starlette.exceptions import HTTPException
 from starlette.status import HTTP_403_FORBIDDEN
 
 # to get a string like this run:
diff --git a/docs/tutorial/handling-errors.md b/docs/tutorial/handling-errors.md
new file mode 100644 (file)
index 0000000..d371f20
--- /dev/null
@@ -0,0 +1,57 @@
+There are many situations in where you need to notify an error to the client that is using your API.
+
+This client could be a browser with a frontend, the code from someone else, an IoT device, etc.
+
+You could need to tell that client that:
+
+* He doesn't have enough privileges for that operation.
+* He doesn't have access to that resource.
+* The item he was trying to access doesn't exist.
+* etc.
+
+In these cases, you would normally return an **HTTP status code** in the range of **400** (from 400 to 499).
+
+This is similar to the 200 HTTP status codes (from 200 to 299). Those "200" status codes mean that somehow there was a "success" in the request.
+
+The status codes in the 400 range mean that there was an error from the client.
+
+Remember all those **"404 Not Found"** errors (and jokes)?
+
+## Use `HTTPException`
+
+To return HTTP responses with errors to the client you use `HTTPException`.
+
+### Import `HTTPException`
+
+```Python hl_lines="1"
+{!./src/handling_errors/tutorial001.py!}
+```
+
+### Raise an `HTTPException` in your code
+
+`HTTPException` is a normal Python exception with additional data relevant for APIs.
+
+Because it's a Python exception, you don't `return` it, you `raise` it.
+
+This also means that if you are inside a utility function that you are calling inside of your path operation function, and you raise the `HTTPException` from inside of that utility function, it won't run the rest of the code in the path operation function, it will terminate that request right away and send the HTTP error from the `HTTPException` to the client.
+
+The benefit of raising an exception over `return`ing a value will be more evident in the section about Dependencies and Security.
+
+In this example, when the client request an item by an ID that doesn't exist, raise an exception with a status code of `404`:
+
+```Python hl_lines="11"
+{!./src/handling_errors/tutorial001.py!}
+```
+
+### Adding 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.
+
+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!}
+```
index 17756468b1475ef11b93a0d4122a47fc3cb0d4ea..49dd5faea39e7d9af6fa53d463d87b30b9728987 100644 (file)
@@ -81,7 +81,7 @@ And another utility to verify if a received password matches the hash stored.
 
 And another one to authenticate and return a user.
 
-```Python hl_lines="7 51 58 59 62 63 72 73 74 75 76 77 78"
+```Python hl_lines="7 50 57 58 61 62 71 72 73 74 75 76 77"
 {!./src/security/tutorial004.py!}
 ```
 
@@ -112,7 +112,7 @@ Define a Pydantic Model that will be used in the token endpoint for the response
 
 Create a utility function to generate a new access token.
 
-```Python hl_lines="3 6 14 15 16 17 31 32 33 81 82 83 84 85 86 87 88 89"
+```Python hl_lines="3 6 13 14 15 16 30 31 32 80 81 82 83 84 85 86 87 88"
 {!./src/security/tutorial004.py!}
 ```
 
@@ -124,7 +124,7 @@ Decode the received token, verify it, and return the current user.
 
 If the token is invalid, return an HTTP error right away.
 
-```Python hl_lines="92 93 94 95 96 97 98 99 100 101"
+```Python hl_lines="91 92 93 94 95 96 97 98 99 100"
 {!./src/security/tutorial004.py!}
 ```
 
@@ -134,7 +134,7 @@ Create a `timedelta` with the expiration time of the token.
 
 Create a real JWT access token and return it.
 
-```Python hl_lines="115 116 117 118 119"
+```Python hl_lines="114 115 116 117 118"
 {!./src/security/tutorial004.py!}
 ```
 
index 6aa56767f8a3fd5894df4502bc1f10d44054f10d..13d44b96772fc674968508f32a0efebee56e1c33 100644 (file)
@@ -78,9 +78,9 @@ Now, get the user data from the (fake) database, using the `username` from the f
 
 If there is no such user, we return an error saying "incorrect username or password".
 
-For the error, we use the exception `HTTPException` provided by Starlette directly:
+For the error, we use the exception `HTTPException`:
 
-```Python hl_lines="4 74 75 76"
+```Python hl_lines="1 73 74 75"
 {!./src/security/tutorial003.py!}
 ```
 
@@ -108,7 +108,7 @@ If your database is stolen, the thief won't have your users' plaintext passwords
 
 So, the thief won't be able to try to use that password in another system (as many users use the same password everywhere, this would be dangerous).
 
-```Python hl_lines="77 78 79 80"
+```Python hl_lines="76 77 78 79"
 {!./src/security/tutorial003.py!}
 ```
 
@@ -146,7 +146,7 @@ For this simple example, we are going to just be completely insecure and return
 
     But for now, let's focus on the specific details we need.
 
-```Python hl_lines="82"
+```Python hl_lines="81"
 {!./src/security/tutorial003.py!}
 ```
 
@@ -162,7 +162,7 @@ Both of these dependencies will just return an HTTP error if the user doesn't ex
 
 So, in our endpoint, we will only get a user if the user exists, was correctly authenticated, and is active:
 
-```Python hl_lines="57 58 59 60 61 62 63 66 67 68 69 86"
+```Python hl_lines="56 57 58 59 60 61 62 65 66 67 68 85"
 {!./src/security/tutorial003.py!}
 ```
 
index 37cb50e7ba84a8efd118a396ee187562dce5e28a..2449014ce074a8ad50ce65bc9e8a049489ce9065 100644 (file)
@@ -5,3 +5,4 @@ __version__ = "0.4.0"
 from .applications import FastAPI
 from .routing import APIRouter
 from .params import Body, Path, Query, Header, Cookie, Form, File, Security, Depends
+from .exceptions import HTTPException
index ac47b130f910543c6dd39b84f062670b94ad5673..988f61bb741084378e23c91b8f33bee90989f34a 100644 (file)
@@ -13,7 +13,13 @@ from starlette.responses import JSONResponse, Response
 
 
 async def http_exception(request: Request, exc: HTTPException) -> JSONResponse:
-    return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
+    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):
diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py
new file mode 100644 (file)
index 0000000..aed0015
--- /dev/null
@@ -0,0 +1,9 @@
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+
+class HTTPException(StarletteHTTPException):
+    def __init__(
+        self, status_code: int, detail: str = None, headers: dict = None
+    ) -> None:
+        super().__init__(status_code=status_code, detail=detail)
+        self.headers = headers
index ac7abfc504f9378e6222e446f6931d46b903cea4..74d3a827ab733e3a362083df5e86780fff70e40f 100644 (file)
@@ -40,6 +40,7 @@ nav:
         - Form Data: 'tutorial/request-forms.md'
         - Request Files: 'tutorial/request-files.md'
         - Request Forms and Files: 'tutorial/request-forms-and-files.md'
+        - Handling Errors: 'tutorial/handling-errors.md'
         - Path Operation Configuration: 'tutorial/path-operation-configuration.md'
         - Path Operation Advanced Configuration: 'tutorial/path-operation-advanced-configuration.md'
         - Custom Response: 'tutorial/custom-response.md'
diff --git a/tests/test_starlette_exception.py b/tests/test_starlette_exception.py
new file mode 100644 (file)
index 0000000..87fdfc9
--- /dev/null
@@ -0,0 +1,156 @@
+from fastapi import FastAPI, HTTPException
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.testclient import TestClient
+
+app = FastAPI()
+
+items = {"foo": "The Foo Wrestlers"}
+
+
+@app.get("/items/{item_id}")
+async def create_item(item_id: str):
+    if item_id not in items:
+        raise HTTPException(
+            status_code=404,
+            detail="Item not found",
+            headers={"X-Error": "Some custom header"},
+        )
+    return {"item": items[item_id]}
+
+
+@app.get("/starlette-items/{item_id}")
+async def create_item(item_id: str):
+    if item_id not in items:
+        raise StarletteHTTPException(status_code=404, detail="Item not found")
+    return {"item": items[item_id]}
+
+
+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": "Create Item Get",
+                "operationId": "create_item_items__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "string"},
+                        "name": "item_id",
+                        "in": "path",
+                    }
+                ],
+            }
+        },
+        "/starlette-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": "Create Item Get",
+                "operationId": "create_item_starlette-items__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "string"},
+                        "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_item():
+    response = client.get("/items/foo")
+    assert response.status_code == 200
+    assert response.json() == {"item": "The Foo Wrestlers"}
+
+
+def test_get_item_not_found():
+    response = client.get("/items/bar")
+    assert response.status_code == 404
+    assert response.headers.get("x-error") == "Some custom header"
+    assert response.json() == {"detail": "Item not found"}
+
+
+def test_get_starlette_item():
+    response = client.get("/starlette-items/foo")
+    assert response.status_code == 200
+    assert response.json() == {"item": "The Foo Wrestlers"}
+
+
+def test_get_starlette_item_not_found():
+    response = client.get("/starlette-items/bar")
+    assert response.status_code == 404
+    assert response.headers.get("x-error") is None
+    assert response.json() == {"detail": "Item not found"}
diff --git a/tests/test_tutorial/test_handling_errors/__init__.py b/tests/test_tutorial/test_handling_errors/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial001.py b/tests/test_tutorial/test_handling_errors/test_tutorial001.py
new file mode 100644 (file)
index 0000000..fba16b2
--- /dev/null
@@ -0,0 +1,90 @@
+from starlette.testclient import TestClient
+
+from handling_errors.tutorial001 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": "Create Item Get",
+                "operationId": "create_item_items__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "string"},
+                        "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_item():
+    response = client.get("/items/foo")
+    assert response.status_code == 200
+    assert response.json() == {"item": "The Foo Wrestlers"}
+
+
+def test_get_item_not_found():
+    response = client.get("/items/bar")
+    assert response.status_code == 404
+    assert response.headers.get("x-error") is None
+    assert response.json() == {"detail": "Item not found"}
diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial002.py b/tests/test_tutorial/test_handling_errors/test_tutorial002.py
new file mode 100644 (file)
index 0000000..7c2534a
--- /dev/null
@@ -0,0 +1,90 @@
+from starlette.testclient import TestClient
+
+from handling_errors.tutorial002 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "Fast API", "version": "0.1.0"},
+    "paths": {
+        "/items-header/{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": "Create Item Header Get",
+                "operationId": "create_item_header_items-header__item_id__get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "Item_Id", "type": "string"},
+                        "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_item_header():
+    response = client.get("/items-header/foo")
+    assert response.status_code == 200
+    assert response.json() == {"item": "The Foo Wrestlers"}
+
+
+def test_get_item_not_found_header():
+    response = client.get("/items-header/bar")
+    assert response.status_code == 404
+    assert response.headers.get("x-error") == "There goes my error"
+    assert response.json() == {"detail": "Item not found"}