powertools-lambda-python icon indicating copy to clipboard operation
powertools-lambda-python copied to clipboard

Feature request: add exception handlers return types to OpenAPI responses

Open ThomasLeedsLRH opened this issue 1 year ago • 7 comments

Use case

I use an API Gateway event handler with validation enabled and I'd like exception handlers response types to be automatically add to the openAPI schema. I can add responses parameter to my endpoint definition, however it's not ideal as it adds a lot off repetition, duplication and introduce potential for incorrect openAPI schemas.

Solution/User Experience

import requests
from typing import List, Optional

from pydantic import BaseModel, Field
from aws_lambda_powertools.utilities.typing import LambdaContext
from aws_lambda_powertools.event_handler import APIGatewayRestResolver

app = APIGatewayRestResolver(enable_validation=True)


class Todo(BaseModel):
   user_id: int = Field(alias="userId")
   id: int
   title: str
   completed: bool


class Error(BaseModel):
   error: str
   detail: Optional[list[dict]]


@app.exception_handler(Exception)
def internal_server_error(error: Exception) -> Error:
   return Error(error="internal_server_error")


@app.get("/todos")
def get_todos() -> List[Todo]:
   todo = requests.get("https://jsonplaceholder.typicode.com/todos")
   todo.raise_for_status()

   return todo.json()


def lambda_handler(event: dict, context: LambdaContext) -> dict:
   return app.resolve(event, context)

This is what currently gets generated:

openapi: 3.0.3
info:
title: Powertools API
version: 1.0.0
servers:
- url: /
paths:
/todos:
   get:
     operationId: get_todos_get
     responses:
       '200':
         description: Successful Response
         content:
           application/json:
             schema:
               items:
                 $ref: '#/components/schemas/Todo'
               type: array
               title: Return
       '422':
         description: Validation Error
         content:
           application/json:
             schema:
               $ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
   HTTPValidationError:
     properties:
       detail:
         items:
           $ref: '#/components/schemas/ValidationError'
         type: array
         title: Detail
     type: object
     title: HTTPValidationError
   Todo:
     properties:
       userId:
         type: integer
         title: Userid
       id:
         type: integer
         title: Id
       title:
         type: string
         title: Title
       completed:
         type: boolean
         title: Completed
     type: object
     required:
       - userId
       - id
       - title
       - completed
     title: Todo
   ValidationError:
     properties:
       loc:
         items:
           anyOf:
             - type: string
             - type: integer
         type: array
         title: Location
       type:
         type: string
         title: Error Type
     type: object
     required:
       - loc
       - msg
       - type
     title: ValidationError

ideally this would be genrated:

openapi: 3.0.3
info:
title: Powertools API
version: 1.0.0
servers:
- url: /
paths:
/todos:
   get:
     operationId: get_todos_get
     responses:
       '200':
         description: Successful Response
         content:
           application/json:
             schema:
               items:
                 $ref: '#/components/schemas/Todo'
               type: array
               title: Return
       '422':
         description: Validation Error
         content:
           application/json:
             schema:
               $ref: '#/components/schemas/HTTPValidationError'
       '500':
         description: Internal Server Error
         content:
           application/json:
             schema:
               $ref: '#/components/schemas/Error'
components:
schemas:
   HTTPValidationError:
     properties:
       detail:
         items:
           $ref: '#/components/schemas/ValidationError'
         type: array
         title: Detail
     type: object
     title: HTTPValidationError
   Todo:
     properties:
       userId:
         type: integer
         title: Userid
       id:
         type: integer
         title: Id
       title:
         type: string
         title: Title
       completed:
         type: boolean
         title: Completed
     type: object
     required:
       - userId
       - id
       - title
       - completed
     title: Todo
   ValidationError:
     properties:
       loc:
         items:
           anyOf:
             - type: string
             - type: integer
         type: array
         title: Location
       type:
         type: string
         title: Error Type
     type: object
     required:
       - loc
       - msg
       - type
     title: ValidationError
   Error:
     properties:
       error:
         type: string
         title: Error
       detail:
         type: array
         items:
           type: object
         title: Detail
     type: object
     required:
       - error

This would require a status code to be attached to each exception_handler and potentially some other context.

Alternative solutions

No response

Acknowledgment

ThomasLeedsLRH avatar Aug 12 '24 12:08 ThomasLeedsLRH

Thanks for opening your first issue here! We'll come back to you as soon as we can. In the meantime, check out the #python channel on our Powertools for AWS Lambda Discord: Invite link

boring-cyborg[bot] avatar Aug 12 '24 12:08 boring-cyborg[bot]

Hi @ThomasLeedsLRH! Thanks for opening this issue and bringing this suggestion.

I don't think we should include exception_handler in the OpenAPI schema. The exception_handler feature is intended to catch general/custom Python exceptions, not define the responses in the OpenAPI schema. I realize there may be some overlap between these features, and the code may get repetitive if you define both, but if we assume we always should include exception_handler in the OpenAPI schema, we may be defining unwanted behavior for some routes that want to treat exceptions in a different way.

I will leave this issue open to hear more from you and other customers.

leandrodamascena avatar Aug 12 '24 15:08 leandrodamascena

Hi @leandrodamascena, my issue is quite similar to this one, so I decided to comment here instead of opening a new issue.

The problem is that the "Validation Error" response is always included in the OpenAPI schema (primarily due to this code), which can lead to incorrect documentation generation when using @app.exception_handler(RequestValidationError).

In my case, I want to return validation errors with a 400 status code and in a different format, like this:

{
    "error": {
        "statusCode": 400,
        "message": "Invalid request parameters",
        "description": {
            "body.user_email": "Field required"
        }
    }
}

To achieve this, I am using a custom exception handler and a custom class:

def validation_error_description(exc: RequestValidationError):
    """
    Extracts and formats validation error messages from a RequestValidationError.
    It creates a dictionary where each key represents the location of the validation error
    in the request (e.g., "body.email"), and the corresponding value is the error message.

    Args:
        exc (RequestValidationError): The exception raised during request validation.

    Returns:
        dict: A dictionary containing detailed descriptions of each validation error.

    """
    error_description = {}

    for error in exc.errors():
        # Creating a string representation of the location (path) of each error in the request
        field = ".".join([str(elem) for elem in error["loc"]])
        # Mapping the error location to its message
        error_description[field] = error["msg"]

    return error_description
    

class ExceptionHandlers:
    """
    A class to handle common exceptions for AWS Lambda functions using AWS Lambda Powertools.

    Attributes:
        app (LambdaPowertoolsApp): An instance of the LambdaPowertoolsApp.
        logger (Logger): An instance of the Powertools Logger.

    """

    def __init__(self, app, logger=None):
        self.app = app
        self.logger = logger or Logger()

    # 400 Bad Request
    def invalid_params(self, exc):
        """
        Handles RequestValidationError exceptions
            by logging the error and returning a custom Response.

        Args:
            exc (RequestValidationError): The exception object.

        Returns:
            Response: A custom response with a status code of 400, indicating a bad request.

        """
        if exc.__class__.__name__ == "TypeError" and "JSON object must be" in str(exc):
            error_description = {"body": "Invalid or empty request body"}
        else:
            if isinstance(exc, RequestValidationError):
                error_description = validation_error_description(exc)
            else:
                error_description = str(exc)

            self.logger.error(
                f"Data validation error: {error_description}",
                extra={
                    "path": self.app.current_event.path,
                    "query_strings": self.app.current_event.query_string_parameters,
                },
            )

        return Response(
            status_code=HTTPStatus.BAD_REQUEST.value,
            content_type=content_types.APPLICATION_JSON,
            body={
                "error": {
                    "statusCode": HTTPStatus.BAD_REQUEST.value,
                    "message": "Invalid request parameters",
                    "description": error_description,
                }
            },
        )

...
exception_handlers = ExceptionHandlers(app=app) 
...
@app.exception_handler([RequestValidationError, ValidationError, ValidationException])
def handle_invalid_params_wrapper(exc):
    return exception_handlers.invalid_params(exc)

However, when I generate the OpenAPI schema, I have to manually add the 400 response to each of my routers:

@router.get(
    "/user/<id>",
    summary="Get user data",
    responses={
        200: {...},
        400: {
            "description": "Bad request",
            "content": {"application/json": {"model": BadRequestResponse}},
      },
    ...

But I also end up with a 422 response in my OpenAPI schema, which shouldn't be there: Screenshot 2024-09-10 at 14 42 34


Ideally, I would like to be able to reuse response models from exception_handler without having to define them for each router.

At the very least, the 422 response should not be added to the OpenAPI, as it can be overridden.

Hatter1337 avatar Sep 10 '24 13:09 Hatter1337

Hi @Hatter1337! Sorry I completely missed this comment! I'm adding it to my backlog to respond to this week, okay?

leandrodamascena avatar Oct 08 '24 16:10 leandrodamascena

Hi @Hatter1337! Sorry I completely missed this comment! I'm adding it to my backlog to respond to this week, okay?

Sure, thank you

Hatter1337 avatar Oct 08 '24 17:10 Hatter1337

Hi @leandrodamascena, sorry for chasing up but is there any update on this?

ThomasLeedsLRH avatar Nov 27 '24 11:11 ThomasLeedsLRH

Hi everyone! I'm adding this issue to investigate again in 3 weeks and have a final decision on how we can solve it!

I was reading in detail and we have 2 situations here:

1 - Customers that don't want to return 422 by default when a response doesn't match the function return.

2 - The ability to automatically add exceptions in the OpenAPI schema. I did some testing and it's not a breaking change if we add this as a flag in the resolver.

Thank you for your patience and I hope to have news soon.

leandrodamascena avatar Feb 20 '25 20:02 leandrodamascena