diff --git a/firetail/apps/flask_app.py b/firetail/apps/flask_app.py index 9294887..e0a7072 100644 --- a/firetail/apps/flask_app.py +++ b/firetail/apps/flask_app.py @@ -2,18 +2,17 @@ This module defines a FlaskApp, a Firetail application to wrap a Flask application. """ -import datetime import logging import pathlib -from decimal import Decimal from types import FunctionType # NOQA import flask import werkzeug.exceptions -from flask import json, signals +from flask import signals from ..apis.flask_api import FlaskApi from ..exceptions import ProblemException +from ..jsonifier import wrap_default from ..problem import problem from .abstract import AbstractApp @@ -33,7 +32,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 @@ -148,24 +147,12 @@ def run(self, port=None, server=None, debug=None, host=None, extra_files=None, * raise Exception(f"Server {self.server} not recognized") -class FlaskJSONEncoder(json.JSONEncoder): +class FlaskJSONProvider(flask.json.provider.DefaultJSONProvider): + """Custom JSONProvider which adds firetail defaults on top of Flask's""" + + @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): diff --git a/firetail/jsonifier.py b/firetail/jsonifier.py index ceffbe5..c8d681d 100644 --- a/firetail/jsonifier.py +++ b/firetail/jsonifier.py @@ -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 Firetail JSON encoder. Handles extra types compared to the - built-in :class:`json.JSONEncoder`. # noqa RST304 +def wrap_default(default_fn: t.Callable) -> t.Callable: + """The Firetail defaults for JSON encoding. Handles extra types compared to the + built-in :class:`json.JSONEncoder`. - - :class:`datetime.datetime` and :class:`datetime.date` are # noqa RST304 + - :class:`datetime.datetime` and :class:`datetime.date` are serialized to :rfc:`822` strings. This is the same as the HTTP date format. - - :class:`uuid.UUID` is serialized to a string. # noqa RST304 + - :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' @@ -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 Firetail 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: @@ -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 diff --git a/firetail/middleware/swagger_ui.py b/firetail/middleware/swagger_ui.py index 88e78a6..3b619c4 100644 --- a/firetail/middleware/swagger_ui.py +++ b/firetail/middleware/swagger_ui.py @@ -12,7 +12,6 @@ from starlette.types import ASGIApp, Receive, Scope, Send from firetail.apis import AbstractSwaggerUIAPI -from firetail.jsonifier import JSONEncoder, Jsonifier from firetail.utils import yamldumper from .base import AppMiddleware @@ -198,7 +197,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) diff --git a/requirements.txt b/requirements.txt index 5981713..19c2e2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ a2wsgi==1.4 clickclick==1.2 -flask[async]==2.2.5 +flask[async]==2.3.3 inflection==0.3.1 jsonschema==4.0.1 PyJWT==2.4.0 PyYAML==6.0.1 requests==2.31.0 starlette==0.27.0 -werkzeug==2.2.3 +werkzeug==3.0.1 aiohttp_jinja2 aiohttp aiohttp_remotes diff --git a/setup.py b/setup.py index 21538c9..c66cf9d 100644 --- a/setup.py +++ b/setup.py @@ -26,14 +26,14 @@ def read_version(package): "PyJWT>=2.4.0", "requests>=2.31,<3", "inflection>=0.3.1,<0.6", - "werkzeug>=2.2.2,<3", + "werkzeug>=3.0.1", "starlette>=0.27,<1", ] swagger_ui_require = "swagger-ui-bundle>=0.0.2,<0.1" flask_require = [ - "flask[async]>=2.2.5,<3.0", + "flask[async]>=2.3.0,<3.0", "a2wsgi>=1.4,<2", ] diff --git a/tests/api/test_parameters.py b/tests/api/test_parameters.py index 87f992b..027e2c0 100644 --- a/tests/api/test_parameters.py +++ b/tests/api/test_parameters.py @@ -516,7 +516,7 @@ def test_get_unicode_request(simple_app): def test_cookie_param(simple_app): app_client = simple_app.app.test_client() - app_client.set_cookie("localhost", "test_cookie", "hello") + app_client.set_cookie("test_cookie", "hello") response = app_client.get("/v1.0/test-cookie-param") assert response.status_code == 200 assert response.json == {"cookie_value": "hello"} diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index 4422ada..3151609 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -2,9 +2,10 @@ from struct import unpack import yaml -from firetail.apps.flask_app import FlaskJSONEncoder from werkzeug.test import Client, EnvironBuilder +from firetail.apps.flask_app import FlaskJSONProvider + def test_app(simple_app): assert simple_app.port == 5001 @@ -242,11 +243,11 @@ def test_nested_additional_properties(simple_openapi_app): def test_custom_encoder(simple_app): - class CustomEncoder(FlaskJSONEncoder): + class CustomEncoder(FlaskJSONProvider): def default(self, o): if o.__class__.__name__ == "DummyClass": return "cool result" - return FlaskJSONEncoder.default(self, o) + return FlaskJSONProvider.default(self, o) flask_app = simple_app.app flask_app.json_encoder = CustomEncoder diff --git a/tests/test_flask_encoder.py b/tests/test_flask_encoder.py index d9e9060..730ec6f 100644 --- a/tests/test_flask_encoder.py +++ b/tests/test_flask_encoder.py @@ -5,25 +5,26 @@ import pytest from conftest import build_app_from_fixture -from firetail.apps.flask_app import FlaskJSONEncoder + +from firetail.apps.flask_app import FlaskJSONProvider SPECS = ["swagger.yaml", "openapi.yaml"] def test_json_encoder(): - s = json.dumps({1: 2}, cls=FlaskJSONEncoder) + s = json.dumps({1: 2}, cls=FlaskJSONProvider) assert '{"1": 2}' == s - s = json.dumps(datetime.date.today(), cls=FlaskJSONEncoder) + s = json.dumps(datetime.date.today(), cls=FlaskJSONProvider) assert len(s) == 12 - s = json.dumps(datetime.datetime.utcnow(), cls=FlaskJSONEncoder) + s = json.dumps(datetime.datetime.utcnow(), cls=FlaskJSONProvider) assert s.endswith('Z"') - s = json.dumps(Decimal(1.01), cls=FlaskJSONEncoder) + s = json.dumps(Decimal(1.01), cls=FlaskJSONProvider) assert s == "1.01" - s = json.dumps(math.expm1(1e-10), cls=FlaskJSONEncoder) + s = json.dumps(math.expm1(1e-10), cls=FlaskJSONProvider) assert s == "1.00000000005e-10" @@ -35,7 +36,7 @@ def utcoffset(self, dt): def dst(self, dt): return datetime.timedelta(0) - s = json.dumps(datetime.datetime.now(DummyTimezone()), cls=FlaskJSONEncoder) + s = json.dumps(datetime.datetime.now(DummyTimezone()), cls=FlaskJSONProvider) assert s.endswith('+00:00"')