Skip to content

Commit 4adcf0e

Browse files
add POST - /collections collection-search (#739)
* add POST - /collections collection-search * fix
1 parent 69dcee0 commit 4adcf0e

File tree

7 files changed

+476
-25
lines changed

7 files changed

+476
-25
lines changed

CHANGES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
### Changed
66

77
* Add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734)
8-
* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#737](https://github.com/stac-utils/stac-fastapi/pull/737))
8+
* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#738](https://github.com/stac-utils/stac-fastapi/pull/738))
99

1010
### Added
1111

1212
* Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655))
13-
* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736))
13+
* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736), [#739](https://github.com/stac-utils/stac-fastapi/pull/739))
1414

1515
## [3.0.0b2] - 2024-07-09
1616

stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""stac_api.extensions.core module."""
22

33
from .aggregation import AggregationExtension
4-
from .collection_search import CollectionSearchExtension
4+
from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension
55
from .context import ContextExtension
66
from .fields import FieldsExtension
77
from .filter import FilterExtension
@@ -24,4 +24,5 @@
2424
"TokenPaginationExtension",
2525
"TransactionExtension",
2626
"CollectionSearchExtension",
27+
"CollectionSearchPostExtension",
2728
)
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
"""Collection-Search extension module."""
22

3-
from .collection_search import CollectionSearchExtension, ConformanceClasses
3+
from .collection_search import (
4+
CollectionSearchExtension,
5+
CollectionSearchPostExtension,
6+
ConformanceClasses,
7+
)
48

5-
__all__ = ["CollectionSearchExtension", "ConformanceClasses"]
9+
__all__ = [
10+
"CollectionSearchExtension",
11+
"CollectionSearchPostExtension",
12+
"ConformanceClasses",
13+
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""collection-search extensions clients."""
2+
3+
import abc
4+
5+
import attr
6+
7+
from stac_fastapi.types import stac
8+
9+
from .request import BaseCollectionSearchPostRequest
10+
11+
12+
@attr.s
13+
class AsyncBaseCollectionSearchClient(abc.ABC):
14+
"""Defines a pattern for implementing the STAC collection-search POST extension."""
15+
16+
@abc.abstractmethod
17+
async def post_all_collections(
18+
self,
19+
search_request: BaseCollectionSearchPostRequest,
20+
**kwargs,
21+
) -> stac.ItemCollection:
22+
"""Get all available collections.
23+
24+
Called with `POST /collections`.
25+
26+
Returns:
27+
A list of collections.
28+
29+
"""
30+
...
31+
32+
33+
@attr.s
34+
class BaseCollectionSearchClient(abc.ABC):
35+
"""Defines a pattern for implementing the STAC collection-search POST extension."""
36+
37+
@abc.abstractmethod
38+
def post_all_collections(
39+
self, search_request: BaseCollectionSearchPostRequest, **kwargs
40+
) -> stac.ItemCollection:
41+
"""Get all available collections.
42+
43+
Called with `POST /collections`.
44+
45+
Returns:
46+
A list of collections.
47+
48+
"""
49+
...

stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
"""Collection-Search extension."""
22

33
from enum import Enum
4-
from typing import List, Optional
4+
from typing import List, Optional, Union
55

66
import attr
7-
from fastapi import FastAPI
7+
from fastapi import APIRouter, FastAPI
8+
from stac_pydantic.api.collections import Collections
9+
from stac_pydantic.shared import MimeTypes
810

11+
from stac_fastapi.api.models import GeoJSONResponse
12+
from stac_fastapi.api.routes import create_async_endpoint
13+
from stac_fastapi.types.config import ApiSettings
914
from stac_fastapi.types.extension import ApiExtension
1015

11-
from .request import CollectionSearchExtensionGetRequest
16+
from .client import AsyncBaseCollectionSearchClient, BaseCollectionSearchClient
17+
from .request import BaseCollectionSearchGetRequest, BaseCollectionSearchPostRequest
1218

1319

1420
class ConformanceClasses(str, Enum):
@@ -46,7 +52,7 @@ class CollectionSearchExtension(ApiExtension):
4652
the extension
4753
"""
4854

49-
GET = CollectionSearchExtensionGetRequest
55+
GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest)
5056
POST = None
5157

5258
conformance_classes: List[str] = attr.ib(
@@ -64,3 +70,65 @@ def register(self, app: FastAPI) -> None:
6470
None
6571
"""
6672
pass
73+
74+
75+
@attr.s
76+
class CollectionSearchPostExtension(CollectionSearchExtension):
77+
"""Collection-Search Extension.
78+
79+
Extents the collection-search extension with an additional
80+
POST - /collections endpoint
81+
82+
NOTE: the POST - /collections endpoint can be conflicting with the
83+
POST /collections endpoint registered for the Transaction extension.
84+
85+
https://github.com/stac-api-extensions/collection-search
86+
87+
Attributes:
88+
conformance_classes (list): Defines the list of conformance classes for
89+
the extension
90+
"""
91+
92+
client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib()
93+
settings: ApiSettings = attr.ib()
94+
conformance_classes: List[str] = attr.ib(
95+
default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS]
96+
)
97+
schema_href: Optional[str] = attr.ib(default=None)
98+
router: APIRouter = attr.ib(factory=APIRouter)
99+
100+
GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest)
101+
POST: BaseCollectionSearchPostRequest = attr.ib(
102+
default=BaseCollectionSearchPostRequest
103+
)
104+
105+
def register(self, app: FastAPI) -> None:
106+
"""Register the extension with a FastAPI application.
107+
108+
Args:
109+
app: target FastAPI application.
110+
111+
Returns:
112+
None
113+
"""
114+
self.router.prefix = app.state.router_prefix
115+
116+
self.router.add_api_route(
117+
name="Collections",
118+
path="/collections",
119+
methods=["POST"],
120+
response_model=(
121+
Collections if self.settings.enable_response_models else None
122+
),
123+
responses={
124+
200: {
125+
"content": {
126+
MimeTypes.json.value: {},
127+
},
128+
"model": Collections,
129+
},
130+
},
131+
response_class=GeoJSONResponse,
132+
endpoint=create_async_endpoint(self.client.post_all_collections, self.POST),
133+
)
134+
app.include_router(self.router)
Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,139 @@
11
"""Request models for the Collection-Search extension."""
22

3-
from typing import Optional
3+
from datetime import datetime as dt
4+
from typing import List, Optional, Tuple, cast
45

56
import attr
67
from fastapi import Query
8+
from pydantic import BaseModel, Field, field_validator
9+
from stac_pydantic.api.search import SearchDatetime
710
from stac_pydantic.shared import BBox
811
from typing_extensions import Annotated
912

1013
from stac_fastapi.types.rfc3339 import DateTimeType
11-
from stac_fastapi.types.search import APIRequest, _bbox_converter, _datetime_converter
14+
from stac_fastapi.types.search import (
15+
APIRequest,
16+
Limit,
17+
_bbox_converter,
18+
_datetime_converter,
19+
)
1220

1321

1422
@attr.s
15-
class CollectionSearchExtensionGetRequest(APIRequest):
23+
class BaseCollectionSearchGetRequest(APIRequest):
1624
"""Basics additional Collection-Search parameters for the GET request."""
1725

1826
bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter)
1927
datetime: Optional[DateTimeType] = attr.ib(
2028
default=None, converter=_datetime_converter
2129
)
2230
limit: Annotated[
23-
Optional[int],
31+
Optional[Limit],
2432
Query(
2533
description="Limits the number of results that are included in each page of the response." # noqa: E501
2634
),
2735
] = attr.ib(default=10)
36+
37+
38+
class BaseCollectionSearchPostRequest(BaseModel):
39+
"""Collection-Search POST model."""
40+
41+
bbox: Optional[BBox] = None
42+
datetime: Optional[str] = None
43+
limit: Optional[Limit] = Field(
44+
10,
45+
description="Limits the number of results that are included in each page of the response (capped to 10_000).", # noqa: E501
46+
)
47+
48+
# Private properties to store the parsed datetime values.
49+
# Not part of the model schema.
50+
_start_date: Optional[dt] = None
51+
_end_date: Optional[dt] = None
52+
53+
# Properties to return the private values
54+
@property
55+
def start_date(self) -> Optional[dt]:
56+
"""start date."""
57+
return self._start_date
58+
59+
@property
60+
def end_date(self) -> Optional[dt]:
61+
"""end date."""
62+
return self._end_date
63+
64+
@field_validator("bbox")
65+
@classmethod
66+
def validate_bbox(cls, v: BBox) -> BBox:
67+
"""validate bbox."""
68+
if v:
69+
# Validate order
70+
if len(v) == 4:
71+
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)
72+
else:
73+
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
74+
Tuple[int, int, int, int, int, int], v
75+
)
76+
if max_elev < min_elev:
77+
raise ValueError(
78+
"Maximum elevation must greater than minimum elevation"
79+
)
80+
81+
if xmax < xmin:
82+
raise ValueError(
83+
"Maximum longitude must be greater than minimum longitude"
84+
)
85+
86+
if ymax < ymin:
87+
raise ValueError(
88+
"Maximum longitude must be greater than minimum longitude"
89+
)
90+
91+
# Validate against WGS84
92+
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
93+
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
94+
95+
return v
96+
97+
@field_validator("datetime")
98+
@classmethod
99+
def validate_datetime(cls, value: str) -> str:
100+
"""validate datetime."""
101+
# Split on "/" and replace no value or ".." with None
102+
values = [v if v and v != ".." else None for v in value.split("/")]
103+
104+
# If there are more than 2 dates, it's invalid
105+
if len(values) > 2:
106+
raise ValueError(
107+
"""Invalid datetime range. Too many values.
108+
Must match format: {begin_date}/{end_date}"""
109+
)
110+
111+
# If there is only one date, duplicate to use for both start and end dates
112+
if len(values) == 1:
113+
values = [values[0], values[0]]
114+
115+
# Cast because pylance gets confused by the type adapter and annotated type
116+
dates = cast(
117+
List[Optional[dt]],
118+
[
119+
# Use the type adapter to validate the datetime strings,
120+
# strict is necessary due to pydantic issues #8736 and #8762
121+
SearchDatetime.validate_strings(v, strict=True) if v else None
122+
for v in values
123+
],
124+
)
125+
126+
# If there is a start and end date,
127+
# check that the start date is before the end date
128+
if dates[0] and dates[1] and dates[0] > dates[1]:
129+
raise ValueError(
130+
"Invalid datetime range. Begin date after end date. "
131+
"Must match format: {begin_date}/{end_date}"
132+
)
133+
134+
# Store the parsed dates
135+
cls._start_date = dates[0]
136+
cls._end_date = dates[1]
137+
138+
# Return the original string value
139+
return value

0 commit comments

Comments
 (0)