Skip to content

Commit b3e7cd0

Browse files
replace attr with dataclass + fastapi.Query() for GET models (#714)
* demonstrate issue 713 * move from attr to dataclass+fastapi.Query() for GET models * update migration
1 parent 1062ac4 commit b3e7cd0

File tree

14 files changed

+253
-59
lines changed

14 files changed

+253
-59
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
### Changed
2020

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

2324
## [3.0.0a4] - 2024-06-27

docs/src/migrations/v3.0.0.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pyda
1313

1414
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)
1515

16-
1716
## Deprecation
1817

1918
* 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.
@@ -24,6 +23,49 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma
2423

2524
* `PostFieldsExtension.filter_fields` property has been removed.
2625

26+
## `attr` -> `dataclass` for APIRequest models
27+
28+
Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`.
29+
30+
```python
31+
# before
32+
@attr.s
33+
class CollectionModel(APIRequest):
34+
collections: Optional[str] = attr.ib(default=None, converter=str2list)
35+
36+
# now
37+
@dataclass
38+
class CollectionModel(APIRequest):
39+
collections: Annotated[Optional[str], Query()] = None
40+
41+
def __post_init__(self):
42+
"""convert attributes."""
43+
if self.collections:
44+
self.collections = str2list(self.collections) # type: ignore
45+
46+
```
47+
48+
!!! warning
49+
50+
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)
51+
52+
```python
53+
@dataclass
54+
class A:
55+
value: Annotated[str, Query()]
56+
57+
# THIS WON'T WORK
58+
@dataclass
59+
class B(A):
60+
another_value: Annotated[str, Query(...)]
61+
62+
# DO THIS
63+
@dataclass
64+
class B(A):
65+
another_value: Annotated[str, Query(...)]
66+
value: Annotated[str, Query()]
67+
```
68+
2769
## Middlewares configuration
2870

2971
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).

stac_fastapi/api/stac_fastapi/api/models.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Api request/response models."""
22

33
import importlib.util
4+
from dataclasses import dataclass, make_dataclass
45
from typing import List, Optional, Type, Union
56

6-
import attr
7-
from fastapi import Path
7+
from fastapi import Path, Query
88
from pydantic import BaseModel, create_model
99
from stac_pydantic.shared import BBox
10+
from typing_extensions import Annotated
1011

1112
from stac_fastapi.types.extension import ApiExtension
1213
from stac_fastapi.types.rfc3339 import DateTimeType
@@ -37,11 +38,11 @@ def create_request_model(
3738

3839
mixins = mixins or []
3940

40-
models = [base_model] + extension_models + mixins
41+
models = extension_models + mixins + [base_model]
4142

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

4647
# Handle POST requests
4748
elif all([issubclass(m, BaseModel) for m in models]):
@@ -80,34 +81,43 @@ def create_post_request_model(
8081
)
8182

8283

83-
@attr.s # type:ignore
84+
@dataclass
8485
class CollectionUri(APIRequest):
8586
"""Get or delete collection."""
8687

87-
collection_id: str = attr.ib(default=Path(..., description="Collection ID"))
88+
collection_id: Annotated[str, Path(description="Collection ID")]
8889

8990

90-
@attr.s
91-
class ItemUri(CollectionUri):
91+
@dataclass
92+
class ItemUri(APIRequest):
9293
"""Get or delete item."""
9394

94-
item_id: str = attr.ib(default=Path(..., description="Item ID"))
95+
collection_id: Annotated[str, Path(description="Collection ID")]
96+
item_id: Annotated[str, Path(description="Item ID")]
9597

9698

97-
@attr.s
99+
@dataclass
98100
class EmptyRequest(APIRequest):
99101
"""Empty request."""
100102

101103
...
102104

103105

104-
@attr.s
105-
class ItemCollectionUri(CollectionUri):
106+
@dataclass
107+
class ItemCollectionUri(APIRequest):
106108
"""Get item collection."""
107109

108-
limit: int = attr.ib(default=10)
109-
bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox)
110-
datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval)
110+
collection_id: Annotated[str, Path(description="Collection ID")]
111+
limit: Annotated[int, Query()] = 10
112+
bbox: Annotated[Optional[BBox], Query()] = None
113+
datetime: Annotated[Optional[DateTimeType], Query()] = None
114+
115+
def __post_init__(self):
116+
"""convert attributes."""
117+
if self.bbox:
118+
self.bbox = str2bbox(self.bbox) # type: ignore
119+
if self.datetime:
120+
self.datetime = str_to_interval(self.datetime) # type: ignore
111121

112122

113123
# Test for ORJSON and use it rather than stdlib JSON where supported

stac_fastapi/api/tests/test_models.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22

33
import pytest
4+
from fastapi import Depends, FastAPI
5+
from fastapi.testclient import TestClient
46
from pydantic import ValidationError
57

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

3435
assert model.collections == ["test1", "test2"]
35-
# assert model.filter_crs == "epsg:4326"
36+
assert model.filter_crs == "epsg:4326"
37+
38+
app = FastAPI()
39+
40+
@app.get("/test")
41+
def route(model=Depends(request_model)):
42+
return model
43+
44+
with TestClient(app) as client:
45+
resp = client.get(
46+
"/test",
47+
params={
48+
"collections": "test1,test2",
49+
"filter-crs": "epsg:4326",
50+
"filter-lang": "cql2-text",
51+
},
52+
)
53+
assert resp.status_code == 200
54+
response_dict = resp.json()
55+
assert response_dict["collections"] == ["test1", "test2"]
56+
assert response_dict["filter_crs"] == "epsg:4326"
57+
assert response_dict["filter_lang"] == "cql2-text"
3658

3759

3860
@pytest.mark.parametrize(
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Request model for the Aggregation extension."""
22

3+
from dataclasses import dataclass
34
from typing import List, Optional
45

5-
import attr
6+
from fastapi import Query
7+
from pydantic import Field
8+
from typing_extensions import Annotated
69

710
from stac_fastapi.types.search import (
811
BaseSearchGetRequest,
@@ -11,14 +14,20 @@
1114
)
1215

1316

14-
@attr.s
17+
@dataclass
1518
class AggregationExtensionGetRequest(BaseSearchGetRequest):
1619
"""Aggregation Extension GET request model."""
1720

18-
aggregations: Optional[str] = attr.ib(default=None, converter=str2list)
21+
aggregations: Annotated[Optional[str], Query()] = None
22+
23+
def __post_init__(self):
24+
"""convert attributes."""
25+
super().__post_init__()
26+
if self.aggregations:
27+
self.aggregations = str2list(self.aggregations) # type: ignore
1928

2029

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

24-
aggregations: Optional[List[str]] = attr.ib(default=None)
33+
aggregations: Optional[List[str]] = Field(default=None)

stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Request models for the fields extension."""
22

33
import warnings
4+
from dataclasses import dataclass
45
from typing import Dict, Optional, Set
56

6-
import attr
7+
from fastapi import Query
78
from pydantic import BaseModel, Field
9+
from typing_extensions import Annotated
810

911
from stac_fastapi.types.search import APIRequest, str2list
1012

@@ -68,11 +70,16 @@ def filter_fields(self) -> Dict:
6870
}
6971

7072

71-
@attr.s
73+
@dataclass
7274
class FieldsExtensionGetRequest(APIRequest):
7375
"""Additional fields for the GET request."""
7476

75-
fields: Optional[str] = attr.ib(default=None, converter=str2list)
77+
fields: Annotated[Optional[str], Query()] = None
78+
79+
def __post_init__(self):
80+
"""convert attributes."""
81+
if self.fields:
82+
self.fields = str2list(self.fields) # type: ignore
7683

7784

7885
class FieldsExtensionPostRequest(BaseModel):

stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
"""Filter extension request models."""
22

3+
from dataclasses import dataclass
34
from typing import Any, Dict, Literal, Optional
45

5-
import attr
6+
from fastapi import Query
67
from pydantic import BaseModel, Field
8+
from typing_extensions import Annotated
79

810
from stac_fastapi.types.search import APIRequest
911

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

1214

13-
@attr.s
15+
@dataclass
1416
class FilterExtensionGetRequest(APIRequest):
1517
"""Filter extension GET request model."""
1618

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

2123

2224
class FilterExtensionPostRequest(BaseModel):

stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
"""Pagination extension request models."""
22

3+
from dataclasses import dataclass
34
from typing import Optional
45

5-
import attr
6+
from fastapi import Query
67
from pydantic import BaseModel
8+
from typing_extensions import Annotated
79

810
from stac_fastapi.types.search import APIRequest
911

1012

11-
@attr.s
13+
@dataclass
1214
class GETTokenPagination(APIRequest):
1315
"""Token pagination for GET requests."""
1416

15-
token: Optional[str] = attr.ib(default=None)
17+
token: Annotated[Optional[str], Query()] = None
1618

1719

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

2325

24-
@attr.s
26+
@dataclass
2527
class GETPagination(APIRequest):
2628
"""Page based pagination for GET requests."""
2729

28-
page: Optional[str] = attr.ib(default=None)
30+
page: Annotated[Optional[str], Query()] = None
2931

3032

3133
class POSTPagination(BaseModel):

stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
"""Request model for the Query extension."""
22

3+
from dataclasses import dataclass
34
from typing import Any, Dict, Optional
45

5-
import attr
6+
from fastapi import Query
67
from pydantic import BaseModel
8+
from typing_extensions import Annotated
79

810
from stac_fastapi.types.search import APIRequest
911

1012

11-
@attr.s
13+
@dataclass
1214
class QueryExtensionGetRequest(APIRequest):
1315
"""Query Extension GET request model."""
1416

15-
query: Optional[str] = attr.ib(default=None)
17+
query: Annotated[Optional[str], Query()] = None
1618

1719

1820
class QueryExtensionPostRequest(BaseModel):

stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
# encoding: utf-8
22
"""Request model for the Sort Extension."""
33

4+
from dataclasses import dataclass
45
from typing import List, Optional
56

6-
import attr
7+
from fastapi import Query
78
from pydantic import BaseModel
89
from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel
10+
from typing_extensions import Annotated
911

1012
from stac_fastapi.types.search import APIRequest, str2list
1113

1214

13-
@attr.s
15+
@dataclass
1416
class SortExtensionGetRequest(APIRequest):
1517
"""Sortby Parameter for GET requests."""
1618

17-
sortby: Optional[str] = attr.ib(default=None, converter=str2list)
19+
sortby: Annotated[Optional[str], Query()] = None
20+
21+
def __post_init__(self):
22+
"""convert attributes."""
23+
if self.sortby:
24+
self.sortby = str2list(self.sortby) # type: ignore
1825

1926

2027
class SortExtensionPostRequest(BaseModel):

0 commit comments

Comments
 (0)