cloudpathlib icon indicating copy to clipboard operation
cloudpathlib copied to clipboard

Support FastAPI jsonable_encoder (through docs or Pydantic config)

Open kabirkhan opened this issue 3 years ago • 2 comments

By default, if I have AnyPath as a type in a Pydantic model used by FastAPI in a response_model config for a route, FastAPI cannot resolve an appropriate json encoder and fails with a recursion error.

So currently an example like the one below will fail with a recursion error in fastapi.encoders.jsonable_encoder

from typing import List
from cloudpathlib import Asset
from fastapi import FastAPI
from pydantic import BaseModel


class Asset(BaseModel):
    name: str
    path: AnyPath


app = FastAPI()

assets = [
    Asset(name="my-asset", path="s3://my-bucket/my-asset")
]

@app.get("/assets", response_model=List[Asset])
def assets():
    return assets

Pydantic allows setting custom json_encoders in a model's Config class so I can update my model to fix this error:

class Asset(BaseModel):
    name: str
    path: AnyPath

    class Config:
        json_encoders = {
            AnyPath: str
        }

and FastAPI will use that info in jsonable_encoder to return the str repr of AnyPath. But this is a bit inconvenient so maybe AnyPath as a type should encode this for itself since it's an obvious encoder (always convert to it's str repr) Maybe by patching pydantic.json.ENCODERS_BY_TYPE? Not really sure.

But this issue should probably at least be documented since a lot of people using Pydantic also use FastAPI.

kabirkhan avatar Jan 27 '22 23:01 kabirkhan

But this is a bit inconvenient so maybe AnyPath as a type should encode this for itself since it's an obvious encoder (always convert to it's str repr)

+1 for this request. use jsonable_encoder cannot really solve this issue; consider the following example.

class Asset(BaseModel):
    name: str
    path: AnyPath

    class Config:
        json_encoders = {
            GSPath: str
        }

class B(BaseModel):
    asset: Asset
In [18]: a = Asset(name="123", path="gs://foo/bar")
In [19]: a.json()
Out[19]: '{"name": "123", "path": "gs://foo/bar"}'
In [20]: b = B(asset=a)
In [21]: b.json()

TypeError: Object of type 'GSPath' is not JSON serializable

Which means I need to change Config for all classes that may contains CloudPath..

lucemia avatar Jul 02 '22 18:07 lucemia

@lucemia Subclassing the BaseModel that you use is, I believe, the recommended way of doing this in Pydantic.

I'm not sure there's a good, supported implementation on our side for this at the moment. We'd rather not implement a solution that depends on the internals of Pydantic to work rather than a supported path for registering custom json encoders.

Here's an example:

from pathlib import Path

from cloudpathlib import AnyPath, CloudPath
from pydantic import BaseModel


class CustomBase(BaseModel):
    class Config:
        json_encoders = {
            CloudPath: str,
            Path: str
        }

class Asset(CustomBase):
    name: str
    cloud: CloudPath
    local: Path
    anywhere: AnyPath
        

class B(CustomBase):
    asset: Asset
        
a = Asset(name="123", cloud="gs://foo/bar", local="/hello/my/friend", anywhere="/yes/indeed.txt")
a
#> Asset(name='123', cloud=GSPath('gs://foo/bar'), local=PosixPath('/hello/my/friend'), anywhere=PosixPath('/yes/indeed.txt'))

b = B(asset=a)
b
#> B(asset=Asset(name='123', cloud=GSPath('gs://foo/bar'), local=PosixPath('/hello/my/friend'), anywhere=PosixPath('/yes/indeed.txt')))

print(b.json(indent=2))
#> {
#>   "asset": {
#>     "name": "123",
#>     "cloud": "gs://foo/bar",
#>     "local": "/hello/my/friend",
#>     "anywhere": "/yes/indeed.txt"
#>   }
#> }

pjbull avatar Jul 05 '22 15:07 pjbull