Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update json for Flask 2.3 #1582

Merged
merged 2 commits into from
Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions connexion/apps/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
This module defines a FlaskApp, a Connexion application to wrap a Flask application.
"""

import datetime
import logging
import pathlib
from decimal import Decimal
from types import FunctionType # NOQA

import a2wsgi
import flask
import werkzeug.exceptions
from flask import json, signals
from flask import signals

from connexion import jsonifier

from ..apis.flask_api import FlaskApi
from ..exceptions import ProblemException
Expand All @@ -36,7 +36,7 @@ def __init__(self, import_name, server="flask", extra_files=None, **kwargs):

def create_app(self):
app = flask.Flask(self.import_name, **self.server_args)
app.json_encoder = FlaskJSONEncoder
app.json = FlaskJSONProvider(app)
app.url_map.converters["float"] = NumberConverter
app.url_map.converters["int"] = IntegerConverter
return app
Expand Down Expand Up @@ -183,24 +183,12 @@ def __call__(self, scope, receive, send): # pragma: no cover
return self.middleware(scope, receive, send)


class FlaskJSONEncoder(json.JSONEncoder):
class FlaskJSONProvider(flask.json.provider.DefaultJSONProvider):
"""Custom JSONProvider which adds connexion defaults on top of Flask's"""

@jsonifier.wrap_default
def default(self, o):
if isinstance(o, datetime.datetime):
if o.tzinfo:
# eg: '2015-09-25T23:14:42.588601+00:00'
return o.isoformat("T")
else:
# No timezone present - assume UTC.
# eg: '2015-09-25T23:14:42.588601Z'
return o.isoformat("T") + "Z"

if isinstance(o, datetime.date):
return o.isoformat()

if isinstance(o, Decimal):
return float(o)

return json.JSONEncoder.default(self, o)
return super().default(o)


class NumberConverter(werkzeug.routing.BaseConverter):
Expand Down
29 changes: 25 additions & 4 deletions connexion/jsonifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@
"""

import datetime
import functools
import json
import typing as t
import uuid
from decimal import Decimal


class JSONEncoder(json.JSONEncoder):
"""The default Connexion JSON encoder. Handles extra types compared to the
def wrap_default(default_fn: t.Callable) -> t.Callable:
"""The Connexion defaults for JSON encoding. Handles extra types compared to the
built-in :class:`json.JSONEncoder`.

- :class:`datetime.datetime` and :class:`datetime.date` are
serialized to :rfc:`822` strings. This is the same as the HTTP
date format.
- :class:`decimal.Decimal` is serialized to a float.
- :class:`uuid.UUID` is serialized to a string.
"""

def default(self, o):
@functools.wraps(default_fn)
def wrapped_default(self, o):
if isinstance(o, datetime.datetime):
if o.tzinfo:
# eg: '2015-09-25T23:14:42.588601+00:00'
Expand All @@ -30,10 +35,25 @@ def default(self, o):
if isinstance(o, datetime.date):
return o.isoformat()

if isinstance(o, Decimal):
return float(o)

if isinstance(o, uuid.UUID):
return str(o)

return json.JSONEncoder.default(self, o)
return default_fn(o)

return wrapped_default


class JSONEncoder(json.JSONEncoder):
"""The default Connexion JSON encoder. Handles extra types compared to the
built-in :class:`json.JSONEncoder`.
"""

@wrap_default
def default(self, o):
return super().default(o)


class Jsonifier:
Expand All @@ -48,6 +68,7 @@ def __init__(self, json_=json, **kwargs):
"""
self.json = json_
self.dumps_args = kwargs
self.dumps_args.setdefault("cls", JSONEncoder)

def dumps(self, data, **kwargs):
"""Central point where JSON serialization happens inside
Expand Down
5 changes: 0 additions & 5 deletions connexion/middleware/swagger_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from starlette.types import ASGIApp, Receive, Scope, Send

from connexion.apis import AbstractSwaggerUIAPI
from connexion.jsonifier import JSONEncoder, Jsonifier
from connexion.middleware import AppMiddleware
from connexion.utils import yamldumper

Expand Down Expand Up @@ -207,7 +206,3 @@ async def _get_swagger_ui_config(self, request):
media_type="application/json",
content=self.jsonifier.dumps(self.options.openapi_console_ui_config),
)

@classmethod
def _set_jsonifier(cls):
cls.jsonifier = Jsonifier(cls=JSONEncoder)
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ def read_version(package):
'PyYAML>=5.1,<7',
'requests>=2.27,<3',
'inflection>=0.3.1,<0.6',
'werkzeug>=2,<3',
'werkzeug>=2.2.1,<3',
'starlette>=0.15,<1',
'httpx>=0.15,<1',
]

swagger_ui_require = 'swagger-ui-bundle>=0.0.2,<0.1'

flask_require = [
'flask>=2,<3',
'flask>=2.2,<3',
'a2wsgi>=1.4,<2',
]

Expand Down
10 changes: 5 additions & 5 deletions tests/api/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from struct import unpack

import yaml
from connexion.apps.flask_app import FlaskJSONEncoder
from connexion.apps.flask_app import FlaskJSONProvider
from werkzeug.test import Client, EnvironBuilder


Expand Down Expand Up @@ -279,15 +279,15 @@ def test_nested_additional_properties(simple_openapi_app):
assert response == {"nested": {"object": True}}


def test_custom_encoder(simple_app):
class CustomEncoder(FlaskJSONEncoder):
def test_custom_provider(simple_app):
class CustomProvider(FlaskJSONProvider):
def default(self, o):
if o.__class__.__name__ == "DummyClass":
return "cool result"
return FlaskJSONEncoder.default(self, o)
return super().default(o)

flask_app = simple_app.app
flask_app.json_encoder = CustomEncoder
flask_app.json = CustomProvider(flask_app)
app_client = flask_app.test_client()

resp = app_client.get("/v1.0/custom-json-response")
Expand Down
21 changes: 12 additions & 9 deletions tests/test_flask_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,42 @@
from decimal import Decimal

import pytest
from connexion.apps.flask_app import FlaskJSONEncoder
from connexion.apps.flask_app import FlaskJSONProvider

from conftest import build_app_from_fixture

SPECS = ["swagger.yaml", "openapi.yaml"]


def test_json_encoder():
s = json.dumps({1: 2}, cls=FlaskJSONEncoder)
def test_json_encoder(simple_app):
flask_app = simple_app.app

s = FlaskJSONProvider(flask_app).dumps({1: 2})
assert '{"1": 2}' == s

s = json.dumps(datetime.date.today(), cls=FlaskJSONEncoder)
s = FlaskJSONProvider(flask_app).dumps(datetime.date.today())
assert len(s) == 12

s = json.dumps(datetime.datetime.utcnow(), cls=FlaskJSONEncoder)
s = FlaskJSONProvider(flask_app).dumps(datetime.datetime.utcnow())
assert s.endswith('Z"')

s = json.dumps(Decimal(1.01), cls=FlaskJSONEncoder)
s = FlaskJSONProvider(flask_app).dumps(Decimal(1.01))
assert s == "1.01"

s = json.dumps(math.expm1(1e-10), cls=FlaskJSONEncoder)
s = FlaskJSONProvider(flask_app).dumps(math.expm1(1e-10))
assert s == "1.00000000005e-10"


def test_json_encoder_datetime_with_timezone():
def test_json_encoder_datetime_with_timezone(simple_app):
class DummyTimezone(datetime.tzinfo):
def utcoffset(self, dt):
return datetime.timedelta(0)

def dst(self, dt):
return datetime.timedelta(0)

s = json.dumps(datetime.datetime.now(DummyTimezone()), cls=FlaskJSONEncoder)
flask_app = simple_app.app
s = FlaskJSONProvider(flask_app).dumps(datetime.datetime.now(DummyTimezone()))
assert s.endswith('+00:00"')


Expand Down