developmentseed/geojson-pydantic

FastAPI shows no attributes for models.

TNick opened this issue · 9 comments

TNick commented

This simple FastAPI script:

from fastapi import FastAPI
from geojson_pydantic import FeatureCollection

app = FastAPI(
    title="Test geojson-pydantic",
    version="1.0"
)

@app.get("/{dataset}", response_model=FeatureCollection)
def get_dataset(dataset: str):
    raise NotImplementedError

Creates this result in /docs:
image

See here for a simple project.

Edit:

  • fastapi==0.104.1
  • pydantic==2.5.1
  • uvicorn==0.24.0.post1
  • geojson-pydantic==1.0.1

can you add another pydantic model (outside geojson-pydantic) because I feel this is a pydantic issue not directly a geojson-pydantic one

can you try with pydantic==2.4.1 also

TNick commented

I've edited the code:

from fastapi import FastAPI
from geojson_pydantic import FeatureCollection
from pydantic import BaseModel, Field


class SomeModel(BaseModel):
    lorem: str = Field(..., description="describe me")
    ipsum: int


app = FastAPI(
    title="Test geojson-pydantic",
    version="1.0"
)


@app.get("/{dataset}", response_model=FeatureCollection)
def get_dataset(dataset: str):
    raise NotImplementedError


@app.get("/another/{dataset}", response_model=SomeModel)
def get_another_dataset(dataset: str):
    raise NotImplementedError

which results in:

image

TNick commented

No change with pydantic==2.4.1.

There's definitely something going one with fastapi/pydantic

if you add FeatureCollection as input model, it appears fine 🤷

from fastapi import FastAPI, Body
from geojson_pydantic import FeatureCollection
from pydantic import BaseModel, Field
from typing_extensions import Annotated

class SomeModel(BaseModel):
    lorem: str = Field(..., description="describe me")
    ipsum: int


app = FastAPI(
    title="Test geojson-pydantic",
    version="1.0"
)


@app.get("/{dataset}", response_model=FeatureCollection)
def get_dataset(dataset: str):
    raise NotImplementedError


@app.get("/another/{dataset}", response_model=SomeModel)
def get_another_dataset(dataset: str):
    raise NotImplementedError


@app.get("/{dataset}", response_model=FeatureCollection)
def get_dataset(body: Annotated[FeatureCollection, Body()]):
    raise NotImplementedError
      "FeatureCollection-Input": {
        "properties": {
          "bbox": {
            "anyOf": [
              {
                "prefixItems": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  }
                ],
                "type": "array",
                "maxItems": 4,
                "minItems": 4
              },
              {
                "prefixItems": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "number"
                  }
                ],
                "type": "array",
                "maxItems": 6,
                "minItems": 6
              },
              {
                "type": "null"
              }
            ],
            "title": "Bbox"
          },
          "type": {
            "const": "FeatureCollection",
            "title": "Type"
          },
          "features": {
            "items": {
              "$ref": "#/components/schemas/Feature-Input"
            },
            "type": "array",
            "title": "Features"
          }
        },
        "type": "object",
        "required": [
          "type",
          "features"
        ],
        "title": "FeatureCollection",
        "description": "FeatureCollection Model"
      },
      "FeatureCollection-Output": {
        "type": "object",
        "title": "FeatureCollection",
        "description": "FeatureCollection Model"
      },

interesting that when input the schema is within the openapi docs but not for the output

TNick commented

Replacing _GeoJsonBase with BaseModel seems to generate correct output.

from typing import Any, Dict, Generic, Iterator, List, Literal, Optional, TypeVar, Union

from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator

# from geojson_pydantic.base import _GeoJsonBase
from geojson_pydantic.geometries import Geometry

Props = TypeVar("Props", bound=Union[Dict[str, Any], BaseModel])
Geom = TypeVar("Geom", bound=Geometry)


class Feature(BaseModel, Generic[Geom, Props]):
    """Feature Model"""

    type: Literal["Feature"]
    geometry: Union[Geom, None] = Field(...)
    properties: Union[Props, None] = Field(...)
    id: Optional[Union[StrictInt, StrictStr]] = None

    __geojson_exclude_if_none__ = {"bbox", "id"}

    @field_validator("geometry", mode="before")
    def set_geometry(cls, geometry: Any) -> Any:
        """set geometry from geo interface or input"""
        if hasattr(geometry, "__geo_interface__"):
            return geometry.__geo_interface__

        return geometry


Feat = TypeVar("Feat", bound=Feature)


class FeatureCollection(BaseModel, Generic[Feat]):
    """FeatureCollection Model"""

    type: Literal["FeatureCollection"]
    features: List[Feat]

    def __iter__(self) -> Iterator[Feat]:  # type: ignore [override]
        """iterate over features"""
        return iter(self.features)

    def __len__(self) -> int:
        """return features length"""
        return len(self.features)

    def __getitem__(self, index: int) -> Feat:
        """get feature at a given index"""
        return self.features[index]

narrowing down this to

@model_serializer(when_used="always", mode="wrap")
def clean_model(self, serializer: Any, info: SerializationInfo) -> Dict[str, Any]:
"""Custom Model serializer to match the GeoJSON specification.
Used to remove fields which are optional but cannot be null values.
"""
# This seems like the best way to have the least amount of unexpected consequences.
# We want to avoid forcing values in `exclude_none` or `exclude_unset` which could
# cause issues or unexpected behavior for downstream users.
# ref: https://github.com/pydantic/pydantic/issues/6575
data: Dict[str, Any] = serializer(self)
# Only remove fields when in JSON mode.
if info.mode_is_json():
for field in self.__geojson_exclude_if_none__:
if field in data and data[field] is None:
del data[field]
return data

if we remove ☝️ it's fine!

TNick commented

:)), yep, I just got there myself.

confirmed this is not a geojson-pydantic issue. It's more a FastAPI (or a pydantic) one!

from typing import Any, Dict
from fastapi import FastAPI
from pydantic import BaseModel, Field, SerializationInfo, model_serializer


class SomeModel(BaseModel):
    lorem: str = Field(..., description="describe me")
    ipsum: int

    @model_serializer(when_used="always", mode="wrap")
    def clean_model(self, serializer: Any, info: SerializationInfo) -> Dict[str, Any]:
        data: Dict[str, Any] = serializer(self)
        return data

app = FastAPI()

@app.get("/another/{dataset}", response_model=SomeModel)
def get_another_dataset(dataset: str):
    raise NotImplementedError