connexion icon indicating copy to clipboard operation
connexion copied to clipboard

Regression: Worse POST body handling compared to 2.14.0

Open hwlodarczyk-rtbh opened this issue 1 year ago • 1 comments

Description

In a project we test for 415 status code for one of the endpoints. We are trying to update Python and dependencies to newer versions but some test cases fail in a way that seems like a regression compared to old version. Main issue is handling/decoding non JSON request body for data which in schema is declared as JSON.

Expected behaviour

Both test cases succeed and for both response.json() should return:

{"detail": "Invalid Content-type (), expected JSON data", "status": 415, "title": "Unsupported Media Type", "type": "about:blank"}

Actual behaviour

Test case with bytes respons with

{"type": "about:blank", "title": "Internal Server Error", "detail": "The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.", "status": 500}

and case with base64 string respons with

{"type": "about:blank", "title": "Bad Request", "detail": "Expecting value: line 1 column 1 (char 0)", "status": 400}

At least it's 400 instead 500 but 415 was better and description is worse than before.

Traceback for bytes case:

ERROR    connexion.middleware.exceptions:exceptions.py:97 UnicodeDecodeError('utf-8', b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00', 0, 1, 'invalid start byte')
Traceback (most recent call last):
  File "/hidden_path/lib/python3.12/site-packages/starlette/_exception_handler.py", line 44, in wrapped_app
    await app(scope, receive, sender)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/swagger_ui.py", line 220, in __call__
    await self.router(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 746, in __call__
    await route.handle(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 459, in handle
    await self.app(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 775, in __call__
    await self.default(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/swagger_ui.py", line 233, in default_fn
    await self.app(original_scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/routing.py", line 153, in __call__
    await self.router(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 746, in __call__
    await route.handle(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 459, in handle
    await self.app(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 746, in __call__
    await route.handle(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/starlette/routing.py", line 288, in handle
    await self.app(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/routing.py", line 47, in __call__
    await self.next_app(original_scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/abstract.py", line 264, in __call__
    return await operation(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/security.py", line 106, in __call__
    await self.next_app(scope, receive, send)
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/abstract.py", line 264, in __call__
    return await operation(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/hidden_path/lib/python3.12/site-packages/connexion/middleware/request_validation.py", line 140, in __call__
    receive = await validator.wrap_receive(receive, scope=scope)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/hidden_path/lib/python3.12/site-packages/connexion/validators/abstract.py", line 142, in wrap_receive
    body = await self._parse(stream(), scope=scope)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/hidden_path/lib/python3.12/site-packages/connexion/validators/json.py", line 54, in _parse
    body = bytes_body.decode(self._encoding)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

It is clear here that connexion tries to decode body with assumption that it's possible to decode it as json and additionally doesn't handle thrown UnicodeDecodeError resulting in 500 status code instead something more meaningful. I can't even handle it myself because it happens before endpoint function is run.

Steps to reproduce

Files

.
├── api.yaml
├── app.py
├── parse.py
└── test_parse.py

spec

# api.yaml
openapi: 3.0.0
info:
    title: title
    version: 1.0.0
paths:
    /path:
        post:
            description: Parse html
            operationId: parse.post
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            type: object
                            properties:
                                html:
                                    type: string
                                    format: html
                                    description: HTML to parse.
                                url:
                                    type: string
                                    format: url
                                    description: URL of webpage from which HTML was taken.
                                headers:
                                    type: object
                                    description: Headers of webpage from which HTML was taken.
                            required:
                                - html
                                - url
            responses:
                200:
                    description: Returns HTML text
                    content:
                        application/json:
                            schema:
                                type: object
                                properties:
                                    text:
                                        type: string

endpoint func (kinda doesn't matter because it doesn't even go inside the funcion):

# parse.py
from typing import Any, cast, Dict, Mapping

import flask
from flask import jsonify

# import parsing  # original


def post(body: Mapping[str, Any]) -> flask.Response:
    html = body["html"]
    url: str = body["url"]
    headers: Dict[str, str] = body.get("headers", {})

    #text = parsing.parse_html(html, headers, url)  # original
    text = "hi"  # dummy so it works for you

    return cast(flask.Response, jsonify({"text": text}))

test code:

# test_parse.py
import pytest

from app import app as connexion_app

BASE_64_IMAGE = "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
BYTES_IMAGE = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00"
INVALID_RESPONSE_JSON_KEYS = {"detail", "type", "status", "title"}


@pytest.fixture
def client():
    with connexion_app.test_client() as client:
        return client

@pytest.mark.parametrize(
    "data",
    [BASE_64_IMAGE, BYTES_IMAGE],
)
def test_post_with_image_data_returns_415(data, client):
    response = client.post("/path", data=data)
    assert set(response.json()) == INVALID_RESPONSE_JSON_KEYS
    assert response.status_code == 415

"app" code

# app.py
import connexion

app = connexion.FlaskApp(__name__)
app.add_api(
    specification="api.yaml",
    strict_validation=True,
    validate_responses=True,
)

run

pytest

Additional info:

Old versions: Python 3.10.6 connexion==2.14.0

For old version you have to change code slightly response.json() to response.get_json() and fixture to:

@pytest.fixture
def client():
    return connexion_app.app.test_client()

New versions: Python 3.12.1 connexion==3.0.5

hwlodarczyk-rtbh avatar Feb 02 '24 21:02 hwlodarczyk-rtbh

The only place I have found that should be fixed/changed is https://github.com/spec-first/connexion/blob/211bdb03f6b20f7dc90fe0037e57cd142c48deb5/connexion/validators/json.py#L54 The main issue is that

body = bytes_body.decode(self._encoding)

should we wrapped in try-except like:

try:
    body = bytes_body.decode(self._encoding)
except Exception:
    body = None  # this or return None

then 500 in one test case will change to 400 so both will return 400 instead 415.

Changing exceptions raised by this method from BadRequestProblem to UnsupportedMediaTypeProblem won't help to bring back previous behaviors as it seems nothing catches UnsupportedMediaTypeProblem in calling code because 500 status code is returned instead 400 or 415.

hwlodarczyk-rtbh avatar Feb 14 '24 18:02 hwlodarczyk-rtbh