How to customize error schema in FastApi and Pydantic? - python

I make FastAPI application I face to structural problem.
For example, let's say there is exist this simple application
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
class PydanticSchema(BaseModel):
test_int: int = Field(..., ge=0)
class CustomException(Exception):
def __init__(self, status_code, msg):
self.status_code = status_code
self.msg = msg
#app.exception_handler(CustomException)
async def handle_custom_exception(request, exc: CustomException):
return JSONResponse(
status_code=exc.status_code, content={"error": exc.msg}
)
#app.post("/")
def index(
some_form: PydanticSchema,
some_header: str = Header("", alias="X-Header", max_length=3),
):
is_special_case = some_form.test_int == 42
if is_special_case:
raise CustomException(418, "Very special case")
return "ok"
And here is the problem.
I have THREE independent producers of errors: My manual raise, Pydantic Model, FastApi.
if __name__ == "__main__":
client = TestClient(app)
cases = (
({"X-Header": "abc"}, {"test_int": 0}),
({"X-Header": "abcdef"}, {"test_int": 0}),
({"X-Header": "abc"}, {"test_int": -1}),
({"X-Header": "abc"}, {"test_int": 42}),
)
for case in cases:
r = client.post("/", headers=case[0], json=case[1])
print(f"#####\n{case=}:\n{r.status_code=}\n{r.text=}")
My goal is to have consistent error responses
So I have to add exception handler for my CustomException,
I have to add error_msg_templates to PydanticModel.Config
I have to add incredibly stupid handler for FastApi exception and Pydantic exception to reformat them into my custom response. Becasuse fastapi overrides custom messages from pydantic templates.
Like so:
# ---snip---
from fastapi.exceptions import RequestValidationError
# ---snip---
class PydanticSchema(BaseModel):
test_int: int = Field(..., ge=0)
class Config:
error_msg_templates = {"value_error.number.not_ge": "Custom GE error"}
# ---snip---
#app.exception_handler(RequestValidationError)
async def fastapi_error_handler(request, exc: RequestValidationError):
errors = exc.errors()
error_wrapper = exc.raw_errors[0]
validation_error = error_wrapper.exc
from pydantic import error_wrappers as ew
if isinstance(validation_error, ew.ValidationError):
errors = validation_error.errors()
first_error = errors[0]
msg = first_error.get("msg")
# [!] Demonstration
etype = first_error.get("type")
if etype == "value_error.any_str.max_length":
msg = "Custom MAX length error"
return JSONResponse(status_code=400, content={"error": msg})
# ---snip---
It is unmaintainable! But custom error message is required - I can not rely ond default pydantic\fastapi error response schemas.
Does anyone one faced to this problem and have solved it gracefully?

Why not FastAPI's Exception_handler argument? You can view the status code of the response when this error occurs, then add an exception handler. Exception handlers have access to the Request & Exception objects, which are the request that caused the exception, and the exception raised, respectively.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
def val_err(request: Request, exception: Exception):
if not isinstance(exception, RequestValidationError):
return
errors = exc.errors()
error_wrapper = exc.raw_errors[0]
validation_error = error_wrapper.exc
from pydantic import error_wrappers as ew
if isinstance(validation_error, ew.ValidationError):
errors = validation_error.errors()
first_error = errors[0]
msg = first_error.get("msg")
# [!] Demonstration
etype = first_error.get("type")
if etype == "value_error.any_str.max_length":
msg = "Custom MAX length error"
return JSONResponse(status_code=400, content={"error": msg})
exception_handlers = {422: val_err}
app = FastAPI(exception_handlers=exception_handlers)
You can refer to my other answer which dissects the code and explains it in a bit more detail.

Related

Flask Error handling not working properly

I have a custom exception shown below where functions are raising these exceptions.
class UnauthorizedToSendException(HTTPException):
code = 400
description = 'Unauthorized to Send.'
They are then defined in Flask's create_app() as follows:
def handle_custom_exceptions(e):
response = {"error": e.description, "message": ""}
if len(e.args) > 0:
response["message"] = e.args[0]
# Add some logging so that we can monitor different types of errors
app.logger.error(f"{e.description}: {response['message']}")
return jsonify(response), e.code
app.register_error_handler(UnauthorizedToSendException, handle_custom_exceptions)
When this exception is raised below:
class LaptopStatus(Resource):
#staticmethod
def get():
raise UnauthorizedToSendException('you are unauthorized to send')
However, the output is always this way:
{
"message": "you are unauthorized to send"
}
Is there something missing here?
flask_restful.Api has its own error handling implementation that mostly replaces Flask's errorhandler functionality, so using the Flask.errorhandler decorator or Flask.register_error_handler won't work.
There are a couple solutions to this.
Don't Inherit from HTTPException and set PROPAGATE_EXCEPTIONS
The flask-restful error routing has short circuits for handling exceptions that don't inherit from HTTPException, and if you set your Flask app to propagate exceptions, it will send the exception to be handled by normal Flask's handlers.
import flask
from flask import Flask
from flask_restful import Resource, Api
app = Flask(__name__)
# Note that this doesn't inherit from HTTPException
class UnauthorizedToSendException(Exception):
code = 400
description = 'Unauthorized to Send'
#app.errorhandler(UnauthorizedToSendException)
def handle_unauth(e: Exception):
rsp = {"error": e.description, "message": ""}
if len(e.args) > 0:
rsp["message"] = e.args[0]
app.logger.error(f"{e.description}: {rsp['message']}")
return flask.jsonify(rsp), e.code
class LaptopStatus(Resource):
#staticmethod
def get():
raise UnauthorizedToSendException("Not authorized")
api = Api(app)
api.add_resource(LaptopStatus, '/status')
if __name__ == "__main__":
# Setting this is important otherwise your raised
# exception will just generate a regular exception
app.config['PROPAGATE_EXCEPTIONS'] = True
app.run()
Running this I get...
#> python flask-test-propagate.py
# ... blah blah ...
#> curl http://localhost:5000/status
{"error":"Unauthorized to Send","message":"Not authorized"}
Replace flask_restul.Api.error_router
This will override the behavior of the Api class's error_router method, and just use the original handler that Flask would use.
You can see it's just a monkey-patch of the class's method with...
Api.error_router = lambda self, hnd, e: hnd(e)
This will allow you to subclass HTTPException and override flask-restful's behavior.
import flask
from flask import Flask
from flask_restful import Resource, Api
from werkzeug.exceptions import HTTPException
# patch the Api class with a custom error router
# that just use's flask's handler (which is passed in as hnd)
Api.error_router = lambda self, hnd, e: hnd(e)
app = Flask(__name__)
class UnauthorizedToSendException(HTTPException):
code = 400
description = 'Unauthorized to Send'
#app.errorhandler(UnauthorizedToSendException)
def handle_unauth(e: Exception):
print("custom!")
rsp = {"error": e.description, "message": ""}
if len(e.args) > 0:
rsp["message"] = e.args[0]
app.logger.error(f"{e.description}: {rsp['message']}")
return flask.jsonify(rsp), e.code
class LaptopStatus(Resource):
#staticmethod
def get():
raise UnauthorizedToSendException("Not authorized")
api = Api(app)
api.add_resource(LaptopStatus, '/status')
if __name__ == "__main__":
app.run()
Switch to using flask.views.MethodView
Documentation:
flask.views.MethodView
Just thought of this, and it's a more drastic change to your code, but flask has facilities for making building REST APIs easier. flask-restful isn't exactly abandoned, but the pace of changes have slowed down the last several years, and various components like the error handling system have become too inflexible.
If you're using flask-restful specifically for the Resource implementation, you can switch to the MethodView, and then you don't need to do any kind of workarounds.
import flask
from flask import Flask
from flask.views import MethodView
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
class UnauthorizedToSendException(HTTPException):
code = 400
description = 'Unauthorized to Send'
#app.errorhandler(UnauthorizedToSendException)
def handle_unauth(e: Exception):
rsp = {"error": e.description, "message": ""}
if len(e.args) > 0:
rsp["message"] = e.args[0]
app.logger.error(f"{e.description}: {rsp['message']}")
return flask.jsonify(rsp), e.code
class LaptopStatusApi(MethodView):
def get(self):
raise UnauthorizedToSendException("Not authorized")
app.add_url_rule("/status", view_func=LaptopStatusApi.as_view("laptopstatus"))
if __name__ == "__main__":
app.run()
read the https://flask.palletsprojects.com/en/2.1.x/errorhandling/
but: See werkzeug.exception for default HTTPException classes.
For HTTP400 (werkzeug.exception.BadRequest)

Fast API with pytest using AsyncClient gives 422 on post?

I'm trying to send a request to an api using pytest through httpx.AsynClient
#pytest.mark.anyio
async def test_device_create_with_data(self, client, random_uuid):
device_create = DeviceCreateFactory.build(subId=random_uuid)
json = device_create.json(by_alias=True)
response = await client.post("/device", json=json)
assert response.status_code == 200
Client fixture:
from httpx import AsyncClient
#pytest.fixture(scope="session")
async def client():
async with AsyncClient(
app=app,
base_url="http://test/api/pc",
headers={"Content-Type": "application/json"}
) as client:
yield client
API endpoint:
#device_router.post("/device", response_model=CommonResponse)
async def create_device(device: DeviceCreate):
_, err = await crud_device.create_device(device)
if err:
return get_common_response(400, err)
return get_common_response(200, "ok")
Schemas:
class DeviceBase(BaseModel):
device_id: StrictStr = Field(..., alias='deviceId')
device_name: StrictStr = Field(..., alias='deviceName')
device_type: StrictStr = Field(..., alias='deviceType')
age_mode: AgeModeType = Field(..., alias='ageMode')
class Config:
allow_population_by_field_name = True
validate_all = True
validate_assignment = True
class DeviceCreate(DeviceBase):
sub_id: StrictStr = Field(..., alias='subId')
class Config:
orm_mode = True
Factory:
from pydantic_factories import ModelFactory
from app.core.schemas.device import DeviceCreate
class DeviceCreateFactory(ModelFactory):
__model__ = DeviceCreate
And i'm getting a 422 error with following response content:
"message":"bad request","details":{"deviceId":"field required","deviceName":"field required","deviceType":"field required","ageMode":"field required","subId":"field required"}
Then i examined the data of the request being sent and got:
b'"{\\"deviceId\\": \\"de\\", \\"deviceName\\": \\"\\", \\"deviceType\\": \\"\\", \\"ageMode\\": \\"child\\", \\"subId\\": \\"11aded61-9966-4be1-a781-387f75346811\\"}"'
Seems like everything is okay, but where is the trouble then?
I've tried to examine the request data in exception handler of 422
I've made:
#app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
print(await request.json())
response = validation_error_response(exc)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder(response.dict())
)
But code after print is unreachable, because await request.json() never ends and runs forever trying to print a request json
Is there a way to manage this problem?
Thanks for any suggest!
P.S.
python version: 3.8.9
fastapi version: 0.68.1
httpx version: 0.21.1
You're double encoding your content as JSON - you're both asking for it to be returned as a JSON string, and then telling your request method to encode it as JSON a second time. json= as an argument to the method on the client converts the given data to JSON - it does not expect already serialized JSON.
You can see this in your request string because it starts with " and not with { as you'd expect:
b'"{\
^
Instead build your model around a dictionary - or as I'd prefer in a test - build the request by hand, so that you're testing how you imagine an actual request should look like.
You can use dict in the same was as you'd use json for a Pydantic model:
device_create = DeviceCreateFactory.build(subId=random_uuid)
response = await client.post("/device", json=device_create.dict(by_alias=True))
assert response.status_code == 200

Python - mock function and assert exception

I built an API with FastAPI that interacts with DynamoDB.
In the beginning of my journey in Test Driven Development, I have doubts about what to mock.
This is the get method, main.py:
router = FastAPI()
#router.get("/{device_id}")
def get_data(request: Request, device_id: str, query: DataQuery = Depends(DataQuery.depends)):
da_service = DaService()
try:
start_time, end_time = DaService.validate_dates(query.start, query.end)
return 'OK'
except WrongDataFormat as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail='Internal Server Error')
In the test file I started by creating the success test, test_main.py:
from fastapi.testclient import TestClient
from unittest import mock
from utils.exceptions import WrongDataFormat
from endpoints.datalake import router
client = TestClient(router)
def test_success_response():
with mock.patch('endpoints.datalake.DataApiService.get_datalake_data'):
response = client.get('/xxxxx', params = {'start': '1629886483', 'end': '1629886504'})
assert response.status_code == 200
assert isinstance(response.json(), dict)
Now I want to create the test for when the exception WrongDataFormat is returned, but I'm not succeeding... This is what I have right now:
def test_exception_response_():
response = client.get('/xxxxx', params = {'2021-08-28', 'end': '2021-12-25'})
assert response.status_code == 400
How can I mock the function main.validate_dates to return the exception WrongDataFormat and assert it correctly?
If you want to test the status code and message of a response you have to use TestClient(app) where app is the FastAPI application. Converting the exception into the appropriate response is the task of the application, not the router (which is what you're testing with).
client = TestClient(app)
This way you can test the API of your application (which is the most useful surface to test, imho).

FastAPI - Supporting multiple authentication dependencies

Problem
I currently have JWT dependency named jwt which makes sure it passes JWT authentication stage before hitting the endpoint like this:
sample_endpoint.py:
from fastapi import APIRouter, Depends, Request
from JWTBearer import JWTBearer
from jwt import jwks
router = APIRouter()
jwt = JWTBearer(jwks)
#router.get("/test_jwt", dependencies=[Depends(jwt)])
async def test_endpoint(request: Request):
return True
Below is the JWT dependency which authenticate users using JWT (source: https://medium.com/datadriveninvestor/jwt-authentication-with-fastapi-and-aws-cognito-1333f7f2729e):
JWTBearer.py
from typing import Dict, Optional, List
from fastapi import HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, jwk, JWTError
from jose.utils import base64url_decode
from pydantic import BaseModel
from starlette.requests import Request
from starlette.status import HTTP_403_FORBIDDEN
JWK = Dict[str, str]
class JWKS(BaseModel):
keys: List[JWK]
class JWTAuthorizationCredentials(BaseModel):
jwt_token: str
header: Dict[str, str]
claims: Dict[str, str]
signature: str
message: str
class JWTBearer(HTTPBearer):
def __init__(self, jwks: JWKS, auto_error: bool = True):
super().__init__(auto_error=auto_error)
self.kid_to_jwk = {jwk["kid"]: jwk for jwk in jwks.keys}
def verify_jwk_token(self, jwt_credentials: JWTAuthorizationCredentials) -> bool:
try:
public_key = self.kid_to_jwk[jwt_credentials.header["kid"]]
except KeyError:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="JWK public key not found"
)
key = jwk.construct(public_key)
decoded_signature = base64url_decode(jwt_credentials.signature.encode())
return key.verify(jwt_credentials.message.encode(), decoded_signature)
async def __call__(self, request: Request) -> Optional[JWTAuthorizationCredentials]:
credentials: HTTPAuthorizationCredentials = await super().__call__(request)
if credentials:
if not credentials.scheme == "Bearer":
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Wrong authentication method"
)
jwt_token = credentials.credentials
message, signature = jwt_token.rsplit(".", 1)
try:
jwt_credentials = JWTAuthorizationCredentials(
jwt_token=jwt_token,
header=jwt.get_unverified_header(jwt_token),
claims=jwt.get_unverified_claims(jwt_token),
signature=signature,
message=message,
)
except JWTError:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")
if not self.verify_jwk_token(jwt_credentials):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="JWK invalid")
return jwt_credentials
jwt.py:
import os
import requests
from dotenv import load_dotenv
from fastapi import Depends, HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from app.JWTBearer import JWKS, JWTBearer, JWTAuthorizationCredentials
load_dotenv() # Automatically load environment variables from a '.env' file.
jwks = JWKS.parse_obj(
requests.get(
f"https://cognito-idp.{os.environ.get('COGNITO_REGION')}.amazonaws.com/"
f"{os.environ.get('COGNITO_POOL_ID')}/.well-known/jwks.json"
).json()
)
jwt = JWTBearer(jwks)
async def get_current_user(
credentials: JWTAuthorizationCredentials = Depends(auth)
) -> str:
try:
return credentials.claims["username"]
except KeyError:
HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Username missing")
api_key_dependency.py (very simplified right now, it will be changed):
from fastapi import Security, FastAPI, HTTPException
from fastapi.security.api_key import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN
async def get_api_key(
api_key_header: str = Security(api_key_header)
):
API_KEY = ... getting API KEY logic ...
if api_key_header == API_KEY:
return True
else:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
Question
Depending on the situation, I would like to first check if it has API Key in the header, and if its present, use that to authenticate. Otherwise, I would like to use jwt dependency for authentication. I want to make sure that if either api-key authentication or jwt authentication passes, the user is authenticated. Would this be possible in FastAPI (i.e. having multiple dependencies and if one of them passes, authentication passed). Thank you!
Sorry, got lost with things to do
The endpoint has a unique dependency, call it check from the file check_auth
ENDPOINT
from fastapi import APIRouter, Depends, Request
from check_auth import check
from JWTBearer import JWTBearer
from jwt import jwks
router = APIRouter()
jwt = JWTBearer(jwks)
#router.get("/test_jwt", dependencies=[Depends(check)])
async def test_endpoint(request: Request):
return True
The function check will depend on two separate dependencies, one for api-key and one for JWT. If both or one of these passes, the authentication passes. Otherwise, we raise exception as shown below.
DEPENDENCY
def key_auth(api_key=Header(None)):
if not api_key:
return None
... verification logic goes here ...
def jwt(authorization=Header(None)):
if not authorization:
return None
... verification logic goes here ...
async def check(key_result=Depends(jwt_auth), jwt_result=Depends(key_auth)):
if not (key_result or jwt_result):
raise Exception
This worked for me (JWT or APIkey Auth). If both or one of the authentication method passes, the authentication passes.
def jwt_auth(auth: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
if not auth:
return None
## validation logic
return True
def key_auth(apikey_header=Depends(APIKeyHeader(name='X-API-Key', auto_error=False))):
if not apikey_header:
return None
## validation logic
return True
async def jwt_or_key_auth(jwt_result=Depends(jwt_auth), key_result=Depends(key_auth)):
if not (key_result or jwt_result):
raise HTTPException(status_code=401, detail="Not authenticated")
#app.get("/", dependencies=[Depends(jwt_or_key_auth)])
async def root():
return {"message": "Hello World"}

How handle errors on AioHttp using Connexion

I'd like to handle errors using AioHttp and Connexion in my python web apis in the same way Flask does through #app.errorhandler(Exception)
In another words, let's say my services raises SomethingAlreadyExists and I want to return 409 Conflict, rather than add the code below in all my apis:
try:
myservice.create_something(..)
except SomethingAlreadyExists as error: # Repeated code -> DRY
return json_response({"message": str(error)}, status=409)
I'd like to just call the myservice.create_something(..) in the API layer and the error handle would return the 409 for SomethingAlreadyExists exceptions or 404 for SomethingNotFound.
Note:
In Flask land it would be something like below:
import connexion
def create_api_app(version='api'):
connexion_app = connexion.FlaskApp(__name__, specification_dir='../api/')
connexion_app.add_api('openapi.yaml', validate_responses=True)
app = connexion_app.app
# It intercepts the specific exception and returns the respective status_code
#app.errorhandler(InvalidValueException)
def bad_request_handler(error):
return 'Bad Request: {}'.format(str(error)), 400
#app.errorhandler(NotFoundException)
def not_found_handler(error):
return 'Not found: {}'.format(str(error)), 404
#app.errorhandler(AlreadyExistsException)
def conflict_handler(error):
return 'Conflict: {}'.format(str(error)), 409
# my_service.py
def get_model(i):
model = get_model_or_none(id)
if btask is None:
raise NotFoundException(f"Model id:{id} not found.")
...
# api.py
def get_model(id):
model = my_service.get_model(id)
# handle errors not required ;)
return btask.to_dict()
I'd like to do the same in my AioHttp connexion app:
from connexion import AioHttpApp
def create_app():
connexion_app = AioHttpApp(__name__, port=8000, specification_dir="../", only_one_api=True)
connexion_app.add_api("openapi.yaml", pass_context_arg_name="request")
# Do something here.. like
# web.Application(middlewares=[handle_already_exists_errors]) --> doesn't work
# OR
# connex_app.add_error_handler(
# AlreadyExistsException, handle_already_exists_errors) --> doesn't work too
return connexion_app
Cheers and I'll appreciate any help!
Roger
I was digging into the connexion and aiohttp code and figured out a way to do it using middlewares:
import json
from aiohttp.web import middleware
from connexion.lifecycle import ConnexionResponse
from connexion import AioHttpApp
from .exceptions import NotFoundException, AlreadyExistsException
def create_app():
connexion_app = AioHttpApp(
__name__, port=8000, specification_dir="../", only_one_api=True
)
connexion_app.add_api("openapi.yaml", pass_context_arg_name="request")
connexion_app.app.middlewares.append(errors_handler_middleware)
return connexion_app
#middleware
async def errors_handler_middleware(request, handler):
""" Handle standard errors returning response following the connexion style messages"""
try:
response = await handler(request)
return response
except NotFoundException as error:
return json_error(message=str(error), title='Not Found', status_code=404)
except AlreadyExistsException as error:
return json_error(message=str(error), title='Conflict', status_code=409)
def json_error(message, title, status_code):
return ConnexionResponse(
body=json.dumps({
'title': title,
'detail': message,
'status': status_code,
}).encode('utf-8'),
status_code=status_code,
content_type='application/json'
)

Categories

Resources