FastAPI Custom Error Handling Broke Automatic Documentation - python

For our requirements, I made the following changes
I updated the input validation exception code from 422 to 400.
I also modified the default Json error output.
My issue
My FastAPI generated automatic documentation is sill showing default error code and error message format.
My Question
Is it possible to update the API documentation to reflect my change like the correct error code and the right error output format?

Currently there is no simple way. You have to modify the OpenAPI file as described here. Meaning you have to load the dictionary and remove the references to the error 422. Here is a minimal example:
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.exceptions import RequestValidationError
app = FastAPI()
#app.get("/items/")
async def read_items():
return [{"name": "Foo"}]
#app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
"""Your custom validation exception."""
message =
return JSONResponse(
status_code=400,
content={"message": f"Validation error: {exc}"}
)
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi()
# look for the error 422 and removes it
for method in openapi_schema["paths"]:
try:
del openapi_schema["paths"][method]["post"]["responses"]["422"]
except KeyError:
pass
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi

The current way to do it is modifying the generated OpenAPI https://fastapi.tiangolo.com/advanced/extending-openapi/

Related

FastApi : traceback.exc_format return none when using add_exception_handler

I develop with FastApi, and want to contain traceback info in response when error occur;
To do so, I define exception handlers in exception_handler.py :
from fastapi.responses import JSONResponse
from fastapi import status
from fastapi import FastAPI, Request
from traceback import format_exc, print_exc
def general_exception_handler(req: Request, exc: Exception):
'''
Exception handler for unspecified exceptions
'''
tracback_msg = format_exc()
return JSONResponse(
{
"code": status.HTTP_500_INTERNAL_SERVER_ERROR,
"message": f"error info: {tracback_msg}",
# "message": f"error info: {str(exc)}",
"data": "",
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
And attach those handler to fastappi app instance in server.py:
server.py is where I create app instance and attach extra function to it like middlewares or exception handlers.
from core import router # api routers are defined in router.py
from fastapi import FastAPI
from core.exception_handler import general_exception_handler
app = FastAPI(
debug=False,
docs_url=None,
redoc_url=None
)
# attach exception handler to app instance
app.add_exception_handler(Exception, general_exception_handler)
# include routers to app intance
app.include_router(router.router)
The problem is, when exception was raised, traceback message return by format_exc() is None;But when I used str(exc) like the annotated code, I got the exception info properly but of course without traceback info.
It will not work because the exception handler receives the exception as parameter instead of catching the exception itself, meaning that there is no stacktrace in this case.
If you want to have the stacktrace, you should create a middleware or a custom API router that will actually capture the exception and return the message the way you want. I usually prefer to use a custom API Route instead of using middleware because it is more explicit and gives you more flexibility.
You can write something like this
class MyRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
try:
return await original_route_handler(request)
except Exception as exc:
tracback_msg = format_exc()
# your return goes here
return custom_route_handler
Then you override the default route handler from fastapi
app = FastAPI()
app.router.route_class = MyRoute
It should give you want you want
There's always format_exception, which takes an explicit exception argument, rather than grabbing the current one from sys.exc_info().

Inject additional parameter in request using FastAPI

I'm creating an API gateway using fastAPI for some microservices, which in I authenticate user via Azure AD via fastapi-azure-auth.
So I'm trying to take out the user info from the request object (request.state.user) and inject my own token in it to later pass to other microservices.
I tried using app.middleware,
#app.middleware("http")
async def add_process_time_header(request: Request, call_next):
new_token = generate_token(request.state.user)
request["new_token"] = new_token
response = await call_next(request)
return response
but I'm getting following error at the initialization of the project:
AttributeError: 'State' object has no attribute 'user'
Also I tried creating a decorator:
from functools import wraps
def token_injector(function):
#wraps(function)
def wrap_function(*args, **kwargs):
user: User = kwargs['request'].state.user
new_token = generate_token(user)
kwargs["new_token"] = new_token # or kwargs['request']["new_token"] = new_token
return function(*args, **kwargs)
return wrap_function
and for this approach, I'm getting these errors:
ValueError: [TypeError("'coroutine' object is not iterable"),
TypeError('vars() argument must have dict attribute')]
and
TypeError: 'Request' object does not support item assignment
So does anyone have a solution for this?
UPDATE
Seems like it can't be done in middleware, cause the user info is added after it
I'm not familiar enough with fastapi-azure-auth to know if this would work exactly as you'd need, but you could do something like:
async def get_new_token(request: Request, user_info=Security(azure_scheme, scopes='user_impersonation'):
return 'my_freshly_generated_token:' + request.state.user
app.include_router(api_router, prefix=settings.API_V1_STR, dependencies=[Depends(get_new_token)])
#app.get('/foo')
async def actual_route(new_token: str = Depends(get_new_token)):
# do magic
pass
The actual dependency will only be evaluated once (so the router dependency will evaluate it, the view will get passed the returned value). Make sure to test that I haven't borked user authentication for some reason.
Thanks to #MatsLindh. With a slightly change of his answer, I could find a working solution:
async def get_new_token(request: Request):
# Generate new token
request.state.new_token = 'NEW_TOKEN_HERE'
app.include_router(
api_router,
prefix=settings.API_V1_STR,
dependencies=[Security(azure_scheme, scopes=['user_login'])],
)
#app.get('/foo')
async def actual_route(request: Request = Depends(get_new_token)):
# Find new token in request.state.new_token
pass

Is it possible to run custom code before Swagger validations in a python/flask server stub?

I'm using the swagger editor (OpenApi 2) for creating flask apis in python. When you define a model in swagger and use it as a schema for the body of a request, swagger validates the body before handing the control to you in the X_controller.py files.
I want to add some code before that validation happens (for printing logs for debugging purposes). Swagger just prints to stdout errors like the following and they are not useful when you have a lot of fields (I need the key that isn't valid).
https://host/path validation error: False is not of type 'string'
10.255.0.2 - - [20/May/2020:20:20:20 +0000] "POST /path HTTP/1.1" 400 116 "-" "GuzzleHttp/7"
I know tecnically you can remove the validations in swagger and do them manually in your code but I want to keep using this feature, when it works it's awesome.
Any ideas on how to do this or any alternative to be able to log the requests are welcome.
After some time studying the matter this is what I learnt.
First let's take a look at how a python-flask server made with Swagger Editor works.
Swagger Editor generates the server stub through Swagger Codegen using the definition written in Swagger Editor. This server stub returned by codegen uses the framework Connexion on top of flask to handle all the HTTP requests and responses, including the validation against the swagger definition (swagger.yaml).
Connexion is a framework that makes it easy to develop python-flask servers because it has a lot of functionality you'd have to make yourself already built in, like parameter validation. All we need to do is replace (in this case modify) these connexion validators.
There are three validators:
ParameterValidator
RequestBodyValidator
ResponseValidator
They get mapped to flask by default but we can replace them easily in the __main__.py file as we'll see.
Our goal is to replace the default logs and default error response to some custom ones. I'm using a custom Error model and a function called error_response() for preparing error responses, and Loguru for logging the errors (not mandatory, you can keep the original one).
To make the changes needed, looking at the connexion validators code, we can see that most of it can be reused, we only need to modify:
RequestBodyValidator: __call__() and validate_schema()
ParameterValidator: __call__()
So we only need to create two new classes that extend the original ones, and copy and modify those functions.
Be careful when copying and pasting. This code is based on connexion==1.1.15. If your are on a different version you should base your classes on it.
In a new file custom_validators.py we need:
import json
import functools
from flask import Flask
from loguru import logger
from requests import Response
from jsonschema import ValidationError
from connexion.utils import all_json, is_null
from connexion.exceptions import ExtraParameterProblem
from swagger_server.models import Error
from connexion.decorators.validation import ParameterValidator, RequestBodyValidator
app = Flask(__name__)
def error_response(response: Error) -> Response:
return app.response_class(
response=json.dumps(response.to_dict(), default=str),
status=response.status,
mimetype='application/json')
class CustomParameterValidator(ParameterValidator):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __call__(self, function):
"""
:type function: types.FunctionType
:rtype: types.FunctionType
"""
#functools.wraps(function)
def wrapper(request):
if self.strict_validation:
query_errors = self.validate_query_parameter_list(request)
formdata_errors = self.validate_formdata_parameter_list(request)
if formdata_errors or query_errors:
raise ExtraParameterProblem(formdata_errors, query_errors)
for param in self.parameters.get('query', []):
error = self.validate_query_parameter(param, request)
if error:
response = error_response(Error(status=400, description=f'Error: {error}'))
return self.api.get_response(response)
for param in self.parameters.get('path', []):
error = self.validate_path_parameter(param, request)
if error:
response = error_response(Error(status=400, description=f'Error: {error}'))
return self.api.get_response(response)
for param in self.parameters.get('header', []):
error = self.validate_header_parameter(param, request)
if error:
response = error_response(Error(status=400, description=f'Error: {error}'))
return self.api.get_response(response)
for param in self.parameters.get('formData', []):
error = self.validate_formdata_parameter(param, request)
if error:
response = error_response(Error(status=400, description=f'Error: {error}'))
return self.api.get_response(response)
return function(request)
return wrapper
class CustomRequestBodyValidator(RequestBodyValidator):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __call__(self, function):
"""
:type function: types.FunctionType
:rtype: types.FunctionType
"""
#functools.wraps(function)
def wrapper(request):
if all_json(self.consumes):
data = request.json
if data is None and len(request.body) > 0 and not self.is_null_value_valid:
# the body has contents that were not parsed as JSON
return error_response(Error(
status=415,
description="Invalid Content-type ({content_type}), JSON data was expected".format(content_type=request.headers.get("Content-Type", ""))
))
error = self.validate_schema(data, request.url)
if error and not self.has_default:
return error
response = function(request)
return response
return wrapper
def validate_schema(self, data, url):
if self.is_null_value_valid and is_null(data):
return None
try:
self.validator.validate(data)
except ValidationError as exception:
description = f'Validation error. Attribute "{exception.validator_value}" return this error: "{exception.message}"'
logger.error(description)
return error_response(Error(
status=400,
description=description
))
return None
Once we have our validators, we have to map them to the flask app (__main__.py) using validator_map:
validator_map = {
'parameter': CustomParameterValidator,
'body': CustomRequestBodyValidator,
'response': ResponseValidator,
}
app = connexion.App(__name__, specification_dir='./swagger/', validator_map=validator_map)
app.app.json_encoder = encoder.JSONEncoder
app.add_api(Path('swagger.yaml'), arguments={'title': 'MyApp'})
If you also need to replace the validator I didn't use in this example, just create a custom child class of ResponseValidator and replace it on the validator_map dictionary in __main__.py.
Connexion docs:
https://connexion.readthedocs.io/en/latest/request.html
Forgive me for repeating an answer first posted at https://stackoverflow.com/a/73051652/1630244
Have you tried the Connexion before_request feature? Here's an example that logs the headers and content before Connexion validates the body:
import connexion
import logging
from flask import request
logger = logging.getLogger(__name__)
conn_app = connexion.FlaskApp(__name__)
#conn_app.app.before_request
def before_request():
for h in request.headers:
logger.debug('header %s', h)
logger.debug('data %s', request.get_data())

Raising exceptions with django_rest_framework

I have written a view that decrypts a GPG encrypted file and returns it as plain text. This works fine in general. The problem is, if the file is empty or otherwise contains invalid GPG data, gnupg returns an empty result rather than throw an exception.
I need to be able to do something like this inside decrypt_file to check to see if the decryption failed and raise an error:
if data.ok:
return str(data)
else:
raise APIException(data.status)
If I do this, I see the APIException raised in the Django debug output, but it's not translating to a 500 response to the client. Instead the client gets a 200 response with an empty body. I can raise the APIException in my get method and it sends a 500 response, but there I don't have access to the gnupg error message.
Here is a very simplified version of my view:
from rest_framework.views import APIView
from django.http import FileResponse
from django.core import files
from gnupg import GPG
class FileDownload(APIView):
def decrypt_file(self, file):
gpg = GPG()
data = gpg.decrypt(file.read())
return data
def get(self, request, id, format=None):
f = open('/tmp/foo', 'rb')
file = files.File(f)
return FileResponse(self.decrypt_file(file))
I have read the docs on DRF exception handling here, but it doesn't seem to provide a solution to this problem. I am fairly new to Django and python in general, so it's entirely possible I'm missing something obvious. Helpful advice would be appreciated. Thanks.
If you raise an error in python, every function in the trace re-raise it until someone catch it. You can first declare a exception :
from rest_framework.exceptions import APIException
class ServiceUnavailable(APIException):
status_code = 503
default_detail = 'Service temporarily unavailable, try again later.'
Then in your decrypt_file function, raise this exception if decryption is not successful. You can pass an argument to modify the message. Then, in the get method, you should call decrypt_file function and pass your file as an argument. If any things goes wrong, your function raise that exception and then, get method re-raise it until Django Rest Framework exception handler catch it.
EDIT:
In your decrypt function, do something like this:
from rest_framework.response import Response
def decrypt_file(self, file):
... # your codes
if data.ok:
return str(data)
else:
raise ServiceUnavailable
def get(self, request, id, format=None):
f = open('/tmp/foo', 'rb')
result = decrypt_file(f)
f.close()
return Response({'data': result})

Python nose test failing on JSON response

This is the method in ReportRunner class in report_runner.py in my Flask-Restful app:
class ReportRunner(object):
def __init__(self):
pass
def setup_routes(self, app):
app.add_url_rule("/run_report", view_func=self.run_report)
def request_report(self, key):
# code #
def key_exists(self, key):
# code #
def run_report(self):
key = request.args.get("key", "")
if self.key_exists(key):
self.request_report(report_type, key)
return jsonify(message = "Success! Your report has been created.")
else:
response = jsonify({"message": "Error => report key not found on server."})
response.status_code = 404
return response
and the nose test calls the URL associated with that route
def setUp(self):
self.setup_flask()
self.controller = Controller()
self.report_runner = ReportRunner()
self.setup_route(self.report_runner)
def test_run_report(self):
rr = Report(key = "daily_report")
rr.save()
self.controller.override(self.report_runner, "request_report")
self.controller.expectAndReturn(self.report_runner.request_report("daily_report"), True )
self.controller.replay()
response = self.client.get("/run_report?key=daily_report")
assert_equals({"message": "Success! Your report has been created."}, response.json)
assert_equals(200, response.status_code)
and the test was failing with the following message:
AttributeError: 'Response' object has no attribute 'json'
but according to the docs it seems that this is how you do it. Do I change the return value from the method, or do I need to structure the test differently?
The test is now passing written like this:
json_response = json.loads(response.data)
assert_equals("Success! Your report has been created.", json_response["message"])
but I'm not clear on the difference between the two approaches.
According to Flask API Response object doesn't have attribute json (it's Request object that has it). So, that's why you get exception. Instead, it has generic method get_data() that returns the string representation of response body.
json_response = json.loads(response.get_data())
assert_equals("Success! Your report has been created.", json_response.get("message", "<no message>"))
So, it's close to what you have except:
get_data() is suggested instead of data as API says: This should not be used and will eventually get deprecated.
reading value from dictionary with get() to not generate exception if key is missing but get correct assert about missing message.
Check this Q&A also.

Categories

Resources