Repository: marshmallow-code/flask-smorest

Issue:
Apispec 6.1.0: Accessing API docs throws TypeError: Object of type Decimal is not JSON serializable

The update to 6.1.0 causes an 500 internal server error for me when trying to view my API docs in a browser. Using flask-smorest. No such error in 6.0.2. 

Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 2213, in __call__
    return self.wsgi_app(environ, start_response)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 2193, in wsgi_app
    response = self.handle_exception(e)
               ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
                                                ^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 2190, in wsgi_app
    response = self.full_dispatch_request()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1486, in full_dispatch_request
    rv = self.handle_user_exception(e)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
                                                ^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1484, in full_dispatch_request
    rv = self.dispatch_request()
         ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1469, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/flask_smorest/spec/__init__.py", line 131, in _openapi_json
    json.dumps(self.spec.to_dict(), indent=2), mimetype="application/json"
  File "/usr/local/lib/python3.11/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
          ^^^^^^^^^^^
  File "/usr/local/lib/python3.11/json/encoder.py", line 202, in encode
    chunks = list(chunks)
             ^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/json/encoder.py", line 432, in _iterencode
    yield from _iterencode_dict(o, _current_indent_level)
  File "/usr/local/lib/python3.11/json/encoder.py", line 406, in _iterencode_dict
    yield from chunks
  File "/usr/local/lib/python3.11/json/encoder.py", line 406, in _iterencode_dict
    yield from chunks
  File "/usr/local/lib/python3.11/json/encoder.py", line 406, in _iterencode_dict
    yield from chunks
  [Previous line repeated 3 more times]
  File "/usr/local/lib/python3.11/json/encoder.py", line 439, in _iterencode
    o = _default(o)
        ^^^^^^^^^^^
  File "/usr/local/lib/python3.11/json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type Decimal is not JSON serializable



Package                Version    Editable project location
---------------------- ---------- -------------------------
apispec                6.1.0
Flask                  2.3.2
flask-smorest          0.42.0


Discussion:
ddorian: @lafrech the reason the test fails, is because that value is generated here:
https://github.com/ddorian/apispec/blob/e4e962b08b351caa655fe93045bbe01db4e3582e/src/apispec/ext/marshmallow/field_converter.py#L227

That calls `fields.Decimal.serialize()` which returns a Decimal.
Why were you expecting to return a float? Which is non-exact data type.
At most you would expect it to return a string, which would work, by setting  `dec = ma.fields.Decimal(load_default=Decimal("0.5"), as_string=True)`.

At that code, it's not connected at all to `app.json = ...`, it's no json-conversion at all inside the test.


Patch:
diff --git a/flask_smorest/etag.py b/flask_smorest/etag.py
index 0a3bc0e..5fcef90 100644
--- a/flask_smorest/etag.py
+++ b/flask_smorest/etag.py
@@ -2,13 +2,12 @@
 
 from functools import wraps
 from copy import deepcopy
-import json
 import http
 import warnings
 
 import hashlib
 
-from flask import request
+from flask import request, json
 
 from .exceptions import PreconditionRequired, PreconditionFailed, NotModified
 from .utils import deepupdate, resolve_schema_instance, get_appcontext
@@ -98,11 +97,13 @@ class EtagMixin:
     def _generate_etag(etag_data, extra_data=None):
         """Generate an ETag from data
 
-        etag_data: Data to use to compute ETag (must be json serializable)
+        etag_data: Data to use to compute ETag
         extra_data: Extra data to add before hashing
 
         Typically, extra_data is used to add pagination metadata to the hash.
         It is not dumped through the Schema.
+
+        Data is JSON serialized before hashing using the Flask app JSON serializer.
         """
         if extra_data:
             etag_data = (etag_data, extra_data)
diff --git a/flask_smorest/spec/__init__.py b/flask_smorest/spec/__init__.py
index 4cf624a..c38460a 100644
--- a/flask_smorest/spec/__init__.py
+++ b/flask_smorest/spec/__init__.py
@@ -1,6 +1,5 @@
 """API specification using OpenAPI"""
 
-import json
 import http
 
 import flask
@@ -126,10 +125,9 @@ class DocBlueprintMixin:
 
     def _openapi_json(self):
         """Serve JSON spec file"""
-        # We don't use Flask.jsonify here as it would sort the keys
-        # alphabetically while we want to preserve the order.
         return current_app.response_class(
-            json.dumps(self.spec.to_dict(), indent=2), mimetype="application/json"
+            flask.json.dumps(self.spec.to_dict(), indent=2, sort_keys=False),
+            mimetype="application/json",
         )
 
     def _openapi_redoc(self):
@@ -396,7 +394,9 @@ def print_openapi_doc(format, config_prefix):
     """Print OpenAPI JSON document."""
     config_prefix = normalize_config_prefix(config_prefix)
     if format == "json":
-        click.echo(json.dumps(_get_spec_dict(config_prefix), indent=2))
+        click.echo(
+            flask.json.dumps(_get_spec_dict(config_prefix), indent=2, sort_keys=False)
+        )
     else:  # format == "yaml"
         if HAS_PYYAML:
             click.echo(yaml.dump(_get_spec_dict(config_prefix)))
@@ -415,7 +415,7 @@ def write_openapi_doc(format, output_file, config_prefix):
     config_prefix = normalize_config_prefix(config_prefix)
     if format == "json":
         click.echo(
-            json.dumps(_get_spec_dict(config_prefix), indent=2),
+            flask.json.dumps(_get_spec_dict(config_prefix), indent=2, sort_keys=False),
             file=output_file,
         )
     else:  # format == "yaml"


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 2760bef..1f28575 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,8 +1,11 @@
 """Test Api class"""
 
+import json
+
 import pytest
 from flask import jsonify
 from flask.views import MethodView
+from flask.json.provider import DefaultJSONProvider
 from werkzeug.routing import BaseConverter
 import marshmallow as ma
 import apispec
@@ -522,3 +525,45 @@ class TestApi:
             "API_V2_OPENAPI_VERSION",
         }
         assert len(api_v2.config) == 3
+
+    @pytest.mark.parametrize("openapi_version", ["2.0", "3.0.2"])
+    def test_api_serializes_doc_with_flask_json(self, app, openapi_version):
+        """Check that app.json, not standard json, is used to serialize API doc"""
+
+        class CustomType:
+            """Custom type"""
+
+        class CustomJSONEncoder(json.JSONEncoder):
+            def default(self, object):
+                if isinstance(object, CustomType):
+                    return 42
+                return super().default(object)
+
+        class CustomJsonProvider(DefaultJSONProvider):
+            def dumps(self, obj, **kwargs):
+                return json.dumps(obj, **kwargs, cls=CustomJSONEncoder)
+
+        class CustomSchema(ma.Schema):
+            custom_field = ma.fields.Field(load_default=CustomType())
+
+        app.config["OPENAPI_VERSION"] = openapi_version
+        app.json = CustomJsonProvider(app)
+        api = Api(app)
+        blp = Blueprint("test", "test", url_prefix="/test")
+
+        @blp.route("/")
+        @blp.arguments(CustomSchema)
+        def test(args):
+            pass
+
+        api.register_blueprint(blp)
+
+        with app.app_context():
+            spec_dict = api._openapi_json().json
+
+        if openapi_version == "2.0":
+            schema = spec_dict["definitions"]["Custom"]
+        else:
+            schema = spec_dict["components"]["schemas"]["Custom"]
+
+        assert schema["properties"]["custom_field"]["default"] == 42