Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

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

Strange behavior when return with custom Response class #4998

Closed
9 tasks done
tomy0000000 opened this issue Jun 7, 2022 · 8 comments
Closed
9 tasks done

Strange behavior when return with custom Response class #4998

tomy0000000 opened this issue Jun 7, 2022 · 8 comments
Labels
question Question or problem question-migrate

Comments

@tomy0000000
Copy link

First Check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the FastAPI documentation, with the integrated search.
  • I already searched in Google "How to X in FastAPI" and didn't find any information.
  • I already read and followed all the tutorial in the docs and didn't find an answer.
  • I already checked if it is not related to FastAPI but to Pydantic.
  • I already checked if it is not related to FastAPI but to Swagger UI.
  • I already checked if it is not related to FastAPI but to ReDoc.

Commit to Help

  • I commit to help with one of those options 👆

Example Code

from decimal import Decimal
from typing import Any

import simplejson
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel


class ModelWithDecimal(BaseModel):
    foo: Decimal


def handle_special_types(obj: Any) -> Any:
    if isinstance(obj, BaseModel):
        return obj.dict()
    return str(obj)


class MyJSONResponse(JSONResponse):
    def render(self, content: Any) -> bytes:
        print("MyJSONResponse is called")
        return simplejson.dumps(
            content,
            ensure_ascii=False,
            allow_nan=False,
            indent=None,
            separators=(",", ":"),
            default=handle_special_types,
            use_decimal=True,
        ).encode("utf-8")


app = FastAPI(default_response_class=MyJSONResponse)

pi_model = ModelWithDecimal(foo=Decimal("3.14159265358979323846264338327950288"))


@app.get("/implicit", response_model=ModelWithDecimal)
def implicit():
    return pi_model


@app.get(
    "/explicit-at-path", response_model=ModelWithDecimal, response_class=MyJSONResponse
)
def explicit_path():
    return pi_model


@app.get("/explicit-at-return", response_model=ModelWithDecimal)
def explicit_return():
    return MyJSONResponse(pi_model)

Description

  • I implement a custom MyJSONResponse

    • If the default JSONResponse is used, long decimal places will be trimmed
    • If all goes will, the custom response class will properly encode the long decimal place, so they won't got trimmed
  • I assigned MyJSONResponse as the default response class during app initialization

  • Open the browser to /implicit

  • The expected results should be

{"foo":3.14159265358979323846264338327950288}

but it's actually

{"foo":3.141592653589793}
  • The same behavior also happened at /explicit-at-path
  • However, it works well at /explicit-at-return, where I explicitly initialized the response when returning the data.
  • Inspect the terminal output, you can see
MyJSONResponse is called
INFO:     127.0.0.1:64711 - "GET /implicit HTTP/1.1" 200 OK
MyJSONResponse is called
INFO:     127.0.0.1:64711 - "GET /explicit-at-path HTTP/1.1" 200 OK
MyJSONResponse is called
INFO:     127.0.0.1:64711 - "GET /explicit-at-return HTTP/1.1" 200 OK
  • Which means MyJSONResponse did get called in all path, but somehow only the last one works

I was also wondering how FastAPI handled data after the route function returns data. Specifically, what components did data flow through, in what order?

My naive observation and guessing:

  1. return in route function
  2. fit object to response_model to validate schema
  3. fit object to response_class to encode into JSON or plaintext
  4. final modification at middleware (if there is one)

Please do correct me if I'm wrong, because this is just I imagine how it should be, but doesn't seems it actually is the case.

Operating System

macOS

Operating System Details

I don't think it matters, but incase you believe so, I'm running macOS 12.4.

FastAPI Version

0.78.0

Python Version

3.10.4

Additional Context

No response

@tomy0000000 tomy0000000 added the question Question or problem label Jun 7, 2022
@JarroVGIT
Copy link
Contributor

I cannot reproduce on the exact same specs (MacOS 12.4, python 3.10.4 and FastAPI 0.78.0). And by that, I mean in my case, even in the /explicit-at-return path operation returns a shortened decimal.

@tomy0000000
Copy link
Author

Do you think maybe it has something to do with the version of other modules

anyio==3.6.1
asgiref==3.5.2
click==8.1.3
fastapi==0.78.0
h11==0.13.0
idna==3.3
pydantic==1.9.1
simplejson==3.17.6
sniffio==1.2.0
starlette==0.19.1
typing_extensions==4.2.0
uvicorn==0.17.6

@JarroVGIT
Copy link
Contributor

My pip freeze:
anyio==3.6.1
asgiref==3.5.2
certifi==2022.5.18.1
charset-normalizer==2.0.12
click==8.1.3
fastapi==0.78.0
h11==0.13.0
idna==3.3
Jinja2==3.1.2
MarkupSafe==2.1.1
pydantic==1.9.1
python-multipart==0.0.5
requests==2.27.1
simplejson==3.17.6
six==1.16.0
sniffio==1.2.0
starlette==0.19.1
typing_extensions==4.2.0
urllib3==1.26.9
uvicorn==0.17.6

Don't see relevant differences. I am trying to follow the request through the application to see where the value gets truncated, but I am getting stuck (or actually, confused on what is happening).

@tomy0000000
Copy link
Author

Found a potential reason:
Screen Shot 2022-06-08 at 4 18 10 AM

If you're testing from the swagger interface, the results will also got trimmed by JavaScript. Try visit directly to the path: http://0.0.0.0:8000/explicit-at-return. This should avoid the problem.

@JarroVGIT
Copy link
Contributor

JarroVGIT commented Jun 7, 2022

Alright I traced it back to the default decoders of Pydantic. I don't think the code does what you intent to have it done.

The render() method on a JSONResponse is called after the raw response has already been formulated. The object is then initiated already with an already 'mishandled' decimal value (e.g. truncated). The culprit is in the jsonable_encoder() that FastAPI uses to construct the raw response. It has some cool logic that determines how to encode certain types to jsonable objects. In your case, objects of type decimal.Decimal are transformed to either int or float (hence, the truncated response).

This raw response is then fed into your MyJSONResponse.render() method, but foo is already a float at this point. So, your default handler that you wrote is never executed and if it were, it still wouldn't do anything (as the precision has already been lost).

This was a fun trip through the source code. The good news is, jsonable_encoder can take homemade encoders with the following parameter:

custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None,

This is actually a test case in the official tests. The bad news however, is that you can only use that parameter when calling the method directly.

But, luckily for us, we can leverage the Config class of a BaseModel. Pydantic actually provides us with a way to write custom json-encoders. I have a working example below; it does however result in a str rather than a decimal value but it demonstrates how you can work it:

from decimal import Decimal
import uvicorn
import simplejson
from fastapi import FastAPI
from pydantic import BaseModel

class ModelWithDecimal(BaseModel):
    foo: Decimal
    
    class Config:
        json_encoders = {
            Decimal: lambda a: simplejson.dumps(
            a,
            ensure_ascii=False,
            allow_nan=False,
            indent=None,
            separators=(",", ":"),
            use_decimal=True
        )
        }

app = FastAPI()

pi_model = ModelWithDecimal(foo=Decimal("3.14159265358979323846264338327950288"))


@app.get("/implicit", response_model=ModelWithDecimal)
def implicit():
    return pi_model


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

Result:

{
"foo":"3.14159265358979323846264338327950288",
}

Let me know if this helps!

@JarroVGIT
Copy link
Contributor

JarroVGIT commented Jun 8, 2022

I had a thought this morning on how to get a non-string decimal value in your JSON response. I tried the following:

from decimal import Decimal
from typing import Any
import uvicorn
import simplejson
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel

class ModelWithDecimal(BaseModel):
    foo: Decimal
    
    class Config:
        json_encoders = {
            Decimal: lambda a: a
        }

class MyJSONResponse(JSONResponse):
    def render(self, content: Any) -> bytes:
        print("MyJSONResponse is called")
        v = simplejson.dumps(
            content,
            ensure_ascii=False,
            allow_nan=False,
            indent=None,
            separators=(",", ":"),
            use_decimal=True,
        ).encode("utf-8")
        return v

app = FastAPI(default_response_class=MyJSONResponse)

pi_model = ModelWithDecimal(foo=Decimal("3.14159265358979323846264338327950288"))


@app.get("/implicit", response_model=ModelWithDecimal)
def implicit():
    return pi_model


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

Basically, this tells jsonable_encoder to leave the Decimal object alone, and then when rendering the end response it uses the simplejson logic to create a proper value. When inspecting the return value of render(), it actually gives a proper response (e.g. not truncated decimal). The browser gives a proper response as well:

image

Working example with requirements: https://github.com/JarroVGIT/fastapi-github-issues/tree/master/4998

@tomy0000000
Copy link
Author

tomy0000000 commented Jun 8, 2022

I actually did tried tweaking json_encoders in model config (as this method is well written in the docs and tutorials), and then shifting my focus on custom response class after dozens of failure. Yet I'm not expecting that using an empty lambda with custom response class will work in this case, so much surprising!

@JarroVGIT Thank you so much for your effort in experimenting this case. 👏🏻

@tomy0000000
Copy link
Author

In addition, I was building my new app with FastAPI, Pydantic, and SQLModel, which is why I'm bumping into this case. As I found out @tiangolo implement the Decimal support for Pydantic in pydantic/pydantic#3507. It's sooner or later that many developers using the same stack will have stuck on this issue.

So here's my proposal:

  • Add a new SimpleJSONResponse, similar to UJSONResponse and UJSONResponse for decimal encoding. (as this is the most convenient, best way to the best of my knowledge)
  • Write a page of doc on how to properly handle decimal in FastAPI with some examples. (I believe I've been through lots of issue that are worth documenting down for future developers)

If that sounds good to all, I'm willing to make this happens. 👍🏻

@tiangolo tiangolo reopened this Feb 27, 2023
Repository owner locked and limited conversation to collaborators Feb 27, 2023
@tiangolo tiangolo converted this issue into discussion #6218 Feb 27, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
question Question or problem question-migrate
Projects
None yet
Development

No branches or pull requests

3 participants