Skip to content
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

replace attr with dataclass + fastapi.Query() for GET models #714

Merged
merged 4 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

### Changed

* Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714)
* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717)

## [3.0.0a4] - 2024-06-27
Expand Down
44 changes: 43 additions & 1 deletion docs/src/migrations/v3.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pyda

In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21)


## Deprecation

* the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification.
Expand All @@ -24,6 +23,49 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma

* `PostFieldsExtension.filter_fields` property has been removed.

## `attr` -> `dataclass` for APIRequest models

Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`.

```python
# before
@attr.s
class CollectionModel(APIRequest):
collections: Optional[str] = attr.ib(default=None, converter=str2list)

# now
@dataclass
class CollectionModel(APIRequest):
collections: Annotated[Optional[str], Query()] = None

def __post_init__(self):
"""convert attributes."""
if self.collections:
self.collections = str2list(self.collections) # type: ignore

```

!!! warning

if you want to extend a class with a `required` attribute (without default), you will have to write all the attributes to avoid having *non-default* attributes defined after *default* attributes (ref: https://github.com/stac-utils/stac-fastapi/pull/714/files#r1651557338)

```python
@dataclass
class A:
value: Annotated[str, Query()]

# THIS WON'T WORK
@dataclass
class B(A):
another_value: Annotated[str, Query(...)]

# DO THIS
@dataclass
class B(A):
another_value: Annotated[str, Query(...)]
value: Annotated[str, Query()]
```

## Middlewares configuration

The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442).
Expand Down
40 changes: 25 additions & 15 deletions stac_fastapi/api/stac_fastapi/api/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Api request/response models."""

import importlib.util
from dataclasses import dataclass, make_dataclass
from typing import List, Optional, Type, Union

import attr
from fastapi import Path
from fastapi import Path, Query
from pydantic import BaseModel, create_model
from stac_pydantic.shared import BBox
from typing_extensions import Annotated

from stac_fastapi.types.extension import ApiExtension
from stac_fastapi.types.rfc3339 import DateTimeType
Expand Down Expand Up @@ -37,11 +38,11 @@ def create_request_model(

mixins = mixins or []

models = [base_model] + extension_models + mixins
models = extension_models + mixins + [base_model]
vincentsarago marked this conversation as resolved.
Show resolved Hide resolved

# Handle GET requests
if all([issubclass(m, APIRequest) for m in models]):
return attr.make_class(model_name, attrs={}, bases=tuple(models))
return make_dataclass(model_name, [], bases=tuple(models))

# Handle POST requests
elif all([issubclass(m, BaseModel) for m in models]):
Expand Down Expand Up @@ -80,34 +81,43 @@ def create_post_request_model(
)


@attr.s # type:ignore
@dataclass
class CollectionUri(APIRequest):
"""Get or delete collection."""

collection_id: str = attr.ib(default=Path(..., description="Collection ID"))
collection_id: Annotated[str, Path(description="Collection ID")]


@attr.s
class ItemUri(CollectionUri):
@dataclass
class ItemUri(APIRequest):
"""Get or delete item."""

item_id: str = attr.ib(default=Path(..., description="Item ID"))
collection_id: Annotated[str, Path(description="Collection ID")]
item_id: Annotated[str, Path(description="Item ID")]


@attr.s
@dataclass
class EmptyRequest(APIRequest):
"""Empty request."""

...


@attr.s
class ItemCollectionUri(CollectionUri):
@dataclass
class ItemCollectionUri(APIRequest):
"""Get item collection."""

limit: int = attr.ib(default=10)
bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox)
datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval)
collection_id: Annotated[str, Path(description="Collection ID")]
limit: Annotated[int, Query()] = 10
bbox: Annotated[Optional[BBox], Query()] = None
datetime: Annotated[Optional[DateTimeType], Query()] = None

def __post_init__(self):
"""convert attributes."""
if self.bbox:
self.bbox = str2bbox(self.bbox) # type: ignore
if self.datetime:
self.datetime = str_to_interval(self.datetime) # type: ignore


# Test for ORJSON and use it rather than stdlib JSON where supported
Expand Down
30 changes: 26 additions & 4 deletions stac_fastapi/api/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json

import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError

from stac_fastapi.api.models import create_get_request_model, create_post_request_model
Expand All @@ -26,13 +28,33 @@ def test_create_get_request_model():
datetime="2020-01-01T00:00:00Z",
limit=10,
filter="test==test",
# FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
# hyphen aliases are not properly working
# **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"},
filter_crs="epsg:4326",
filter_lang="cql2-text",
)

assert model.collections == ["test1", "test2"]
# assert model.filter_crs == "epsg:4326"
assert model.filter_crs == "epsg:4326"

app = FastAPI()

@app.get("/test")
def route(model=Depends(request_model)):
return model

with TestClient(app) as client:
resp = client.get(
"/test",
params={
"collections": "test1,test2",
"filter-crs": "epsg:4326",
"filter-lang": "cql2-text",
},
)
assert resp.status_code == 200
response_dict = resp.json()
assert response_dict["collections"] == ["test1", "test2"]
assert response_dict["filter_crs"] == "epsg:4326"
assert response_dict["filter_lang"] == "cql2-text"


@pytest.mark.parametrize(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Request model for the Aggregation extension."""

from dataclasses import dataclass
from typing import List, Optional

import attr
from fastapi import Query
from pydantic import Field
from typing_extensions import Annotated

from stac_fastapi.types.search import (
BaseSearchGetRequest,
Expand All @@ -11,14 +14,20 @@
)


@attr.s
@dataclass
class AggregationExtensionGetRequest(BaseSearchGetRequest):
"""Aggregation Extension GET request model."""

aggregations: Optional[str] = attr.ib(default=None, converter=str2list)
aggregations: Annotated[Optional[str], Query()] = None

def __post_init__(self):
"""convert attributes."""
super().__post_init__()
if self.aggregations:
self.aggregations = str2list(self.aggregations) # type: ignore


class AggregationExtensionPostRequest(BaseSearchPostRequest):
"""Aggregation Extension POST request model."""

aggregations: Optional[List[str]] = attr.ib(default=None)
aggregations: Optional[List[str]] = Field(default=None)
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Request models for the fields extension."""

import warnings
from dataclasses import dataclass
from typing import Dict, Optional, Set

import attr
from fastapi import Query
from pydantic import BaseModel, Field
from typing_extensions import Annotated

from stac_fastapi.types.search import APIRequest, str2list

Expand Down Expand Up @@ -68,11 +70,16 @@ def filter_fields(self) -> Dict:
}


@attr.s
@dataclass
class FieldsExtensionGetRequest(APIRequest):
"""Additional fields for the GET request."""

fields: Optional[str] = attr.ib(default=None, converter=str2list)
fields: Annotated[Optional[str], Query()] = None

def __post_init__(self):
"""convert attributes."""
if self.fields:
self.fields = str2list(self.fields) # type: ignore


class FieldsExtensionPostRequest(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
"""Filter extension request models."""

from dataclasses import dataclass
from typing import Any, Dict, Literal, Optional

import attr
from fastapi import Query
from pydantic import BaseModel, Field
from typing_extensions import Annotated

from stac_fastapi.types.search import APIRequest

FilterLang = Literal["cql-json", "cql2-json", "cql2-text"]


@attr.s
@dataclass
class FilterExtensionGetRequest(APIRequest):
"""Filter extension GET request model."""

filter: Optional[str] = attr.ib(default=None)
filter_crs: Optional[str] = Field(alias="filter-crs", default=None)
filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-text")
filter: Annotated[Optional[str], Query()] = None
filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = None
filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = "cql2-text"


class FilterExtensionPostRequest(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Pagination extension request models."""

from dataclasses import dataclass
from typing import Optional

import attr
from fastapi import Query
from pydantic import BaseModel
from typing_extensions import Annotated

from stac_fastapi.types.search import APIRequest


@attr.s
@dataclass
class GETTokenPagination(APIRequest):
"""Token pagination for GET requests."""

token: Optional[str] = attr.ib(default=None)
token: Annotated[Optional[str], Query()] = None


class POSTTokenPagination(BaseModel):
Expand All @@ -21,11 +23,11 @@ class POSTTokenPagination(BaseModel):
token: Optional[str] = None


@attr.s
@dataclass
class GETPagination(APIRequest):
"""Page based pagination for GET requests."""

page: Optional[str] = attr.ib(default=None)
page: Annotated[Optional[str], Query()] = None


class POSTPagination(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Request model for the Query extension."""

from dataclasses import dataclass
from typing import Any, Dict, Optional

import attr
from fastapi import Query
from pydantic import BaseModel
from typing_extensions import Annotated

from stac_fastapi.types.search import APIRequest


@attr.s
@dataclass
class QueryExtensionGetRequest(APIRequest):
"""Query Extension GET request model."""

query: Optional[str] = attr.ib(default=None)
query: Annotated[Optional[str], Query()] = None


class QueryExtensionPostRequest(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
# encoding: utf-8
"""Request model for the Sort Extension."""

from dataclasses import dataclass
from typing import List, Optional

import attr
from fastapi import Query
from pydantic import BaseModel
from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel
from typing_extensions import Annotated

from stac_fastapi.types.search import APIRequest, str2list


@attr.s
@dataclass
class SortExtensionGetRequest(APIRequest):
"""Sortby Parameter for GET requests."""

sortby: Optional[str] = attr.ib(default=None, converter=str2list)
sortby: Annotated[Optional[str], Query()] = None

def __post_init__(self):
"""convert attributes."""
if self.sortby:
self.sortby = str2list(self.sortby) # type: ignore


class SortExtensionPostRequest(BaseModel):
Expand Down
Loading