Skip to content

Commit 06218c5

Browse files
authored
Add bbox and datetime to /collections/{collectionId}/items (#477)
* Add bbox and datetime to ItemCollectionUri Imported str2list function from stac_fastapi.types.search as already have dependency on it. * Add bbox and datetime to ABC item_collection Using same type signature as get_search * Add bbox and datetime to PgStac item_collection Used the same base_args -> clean from the search routes Probably not necessary in this situation but thought I'd keep it similar (and extensible). * Update CHANGES.md * Add bbox and datetime to SQLAlchemy item_collection Copy the bbox and datetime filtering from the search routes * Fix: convert SQLAlchemy bbox to list[int] * Add item_collection bbox/datetime tests
1 parent 9ee7cb1 commit 06218c5

File tree

7 files changed

+172
-8
lines changed

7 files changed

+172
-8
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
* Add support in pgstac backend for /queryables and /collections/{collection_id}/queryables endpoints with functions exposed in pgstac 0.6.8
8+
* Add `bbox` and `datetime` query parameters to `/collections/{collection_id}/items`. [476](https://github.com/stac-utils/stac-fastapi/issues/476) [380](https://github.com/stac-utils/stac-fastapi/issues/380)
89
* Update pgstac requirement to 0.6.10
910

1011
### Changed

stac_fastapi/api/stac_fastapi/api/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
APIRequest,
1414
BaseSearchGetRequest,
1515
BaseSearchPostRequest,
16+
str2list,
1617
)
1718

1819

@@ -123,6 +124,8 @@ class ItemCollectionUri(CollectionUri):
123124
"""Get item collection."""
124125

125126
limit: int = attr.ib(default=10)
127+
bbox: Optional[str] = attr.ib(default=None, converter=str2list)
128+
datetime: Optional[str] = attr.ib(default=None)
126129

127130

128131
class POSTTokenPagination(BaseModel):

stac_fastapi/pgstac/stac_fastapi/pgstac/core.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ async def _get_base_item(collection_id: str) -> Dict[str, Any]:
248248
async def item_collection(
249249
self,
250250
collection_id: str,
251+
bbox: Optional[List[NumType]] = None,
252+
datetime: Optional[Union[str, datetime]] = None,
251253
limit: Optional[int] = None,
252254
token: str = None,
253255
**kwargs,
@@ -267,8 +269,21 @@ async def item_collection(
267269
# If collection does not exist, NotFoundError wil be raised
268270
await self.get_collection(collection_id, **kwargs)
269271

272+
base_args = {
273+
"collections": [collection_id],
274+
"bbox": bbox,
275+
"datetime": datetime,
276+
"limit": limit,
277+
"token": token,
278+
}
279+
280+
clean = {}
281+
for k, v in base_args.items():
282+
if v is not None and v != []:
283+
clean[k] = v
284+
270285
req = self.post_request_model(
271-
collections=[collection_id], limit=limit, token=token
286+
**clean,
272287
)
273288
item_collection = await self._search_base(req, **kwargs)
274289
links = await CollectionLinks(

stac_fastapi/pgstac/tests/api/test_api.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,54 @@ async def test_collection_queryables(load_test_data, app_client, load_test_colle
417417
assert "eo:cloud_cover" in q["properties"]
418418

419419

420+
@pytest.mark.asyncio
421+
async def test_item_collection_filter_bbox(
422+
load_test_data, app_client, load_test_collection
423+
):
424+
coll = load_test_collection
425+
first_item = load_test_data("test_item.json")
426+
resp = await app_client.post(f"/collections/{coll.id}/items", json=first_item)
427+
assert resp.status_code == 200
428+
429+
bbox = "100,-50,170,-20"
430+
resp = await app_client.get(f"/collections/{coll.id}/items", params={"bbox": bbox})
431+
assert resp.status_code == 200
432+
resp_json = resp.json()
433+
assert len(resp_json["features"]) == 1
434+
435+
bbox = "1,2,3,4"
436+
resp = await app_client.get(f"/collections/{coll.id}/items", params={"bbox": bbox})
437+
assert resp.status_code == 200
438+
resp_json = resp.json()
439+
assert len(resp_json["features"]) == 0
440+
441+
442+
@pytest.mark.asyncio
443+
async def test_item_collection_filter_datetime(
444+
load_test_data, app_client, load_test_collection
445+
):
446+
coll = load_test_collection
447+
first_item = load_test_data("test_item.json")
448+
resp = await app_client.post(f"/collections/{coll.id}/items", json=first_item)
449+
assert resp.status_code == 200
450+
451+
datetime_range = "2020-01-01T00:00:00.00Z/.."
452+
resp = await app_client.get(
453+
f"/collections/{coll.id}/items", params={"datetime": datetime_range}
454+
)
455+
assert resp.status_code == 200
456+
resp_json = resp.json()
457+
assert len(resp_json["features"]) == 1
458+
459+
datetime_range = "2018-01-01T00:00:00.00Z/2019-01-01T00:00:00.00Z"
460+
resp = await app_client.get(
461+
f"/collections/{coll.id}/items", params={"datetime": datetime_range}
462+
)
463+
assert resp.status_code == 200
464+
resp_json = resp.json()
465+
assert len(resp_json["features"]) == 0
466+
467+
420468
@pytest.mark.asyncio
421469
async def test_bad_collection_queryables(
422470
load_test_data, app_client, load_test_collection

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,25 +99,64 @@ def get_collection(self, collection_id: str, **kwargs) -> Collection:
9999
return self.collection_serializer.db_to_stac(collection, base_url)
100100

101101
def item_collection(
102-
self, collection_id: str, limit: int = 10, token: str = None, **kwargs
102+
self,
103+
collection_id: str,
104+
bbox: Optional[List[NumType]] = None,
105+
datetime: Optional[str] = None,
106+
limit: int = 10,
107+
token: str = None,
108+
**kwargs,
103109
) -> ItemCollection:
104110
"""Read an item collection from the database."""
105111
base_url = str(kwargs["request"].base_url)
106112
with self.session.reader.context_session() as session:
107-
collection_children = (
113+
query = (
108114
session.query(self.item_table)
109115
.join(self.collection_table)
110116
.filter(self.collection_table.id == collection_id)
111117
.order_by(self.item_table.datetime.desc(), self.item_table.id)
112118
)
119+
# Spatial query
120+
geom = None
121+
if bbox:
122+
bbox = [float(x) for x in bbox]
123+
if len(bbox) == 4:
124+
geom = ShapelyPolygon.from_bounds(*bbox)
125+
elif len(bbox) == 6:
126+
"""Shapely doesn't support 3d bounding boxes so use the 2d portion"""
127+
bbox_2d = [bbox[0], bbox[1], bbox[3], bbox[4]]
128+
geom = ShapelyPolygon.from_bounds(*bbox_2d)
129+
if geom:
130+
filter_geom = ga.shape.from_shape(geom, srid=4326)
131+
query = query.filter(
132+
ga.func.ST_Intersects(self.item_table.geometry, filter_geom)
133+
)
134+
135+
# Temporal query
136+
if datetime:
137+
# Two tailed query (between)
138+
dts = datetime.split("/")
139+
# Non-interval date ex. "2000-02-02T00:00:00.00Z"
140+
if len(dts) == 1:
141+
query = query.filter(self.item_table.datetime == dts[0])
142+
# is there a benefit to between instead of >= and <= ?
143+
elif dts[0] not in ["", ".."] and dts[1] not in ["", ".."]:
144+
query = query.filter(self.item_table.datetime.between(*dts))
145+
# All items after the start date
146+
elif dts[0] not in ["", ".."]:
147+
query = query.filter(self.item_table.datetime >= dts[0])
148+
# All items before the end date
149+
elif dts[1] not in ["", ".."]:
150+
query = query.filter(self.item_table.datetime <= dts[1])
151+
113152
count = None
114153
if self.extension_is_enabled("ContextExtension"):
115-
count_query = collection_children.statement.with_only_columns(
154+
count_query = query.statement.with_only_columns(
116155
[func.count()]
117156
).order_by(None)
118-
count = collection_children.session.execute(count_query).scalar()
157+
count = query.session.execute(count_query).scalar()
119158
token = self.get_token(token) if token else token
120-
page = get_page(collection_children, per_page=limit, page=(token or False))
159+
page = get_page(query, per_page=limit, page=(token or False))
121160
# Create dynamic attributes for each page
122161
page.next = (
123162
self.insert_token(keyset=page.paging.bookmark_next)

stac_fastapi/sqlalchemy/tests/api/test_api.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,49 @@ def test_app_search_response_duplicate_forwarded_headers(
434434
for feature in resp.json()["features"]:
435435
for link in feature["links"]:
436436
assert link["href"].startswith("https://testserver:1234/")
437+
438+
439+
def test_item_collection_filter_bbox(load_test_data, app_client, postgres_transactions):
440+
item = load_test_data("test_item.json")
441+
collection = item["collection"]
442+
postgres_transactions.create_item(
443+
item["collection"], item, request=MockStarletteRequest
444+
)
445+
446+
bbox = "100,-50,170,-20"
447+
resp = app_client.get(f"/collections/{collection}/items", params={"bbox": bbox})
448+
assert resp.status_code == 200
449+
resp_json = resp.json()
450+
assert len(resp_json["features"]) == 1
451+
452+
bbox = "1,2,3,4"
453+
resp = app_client.get(f"/collections/{collection}/items", params={"bbox": bbox})
454+
assert resp.status_code == 200
455+
resp_json = resp.json()
456+
assert len(resp_json["features"]) == 0
457+
458+
459+
def test_item_collection_filter_datetime(
460+
load_test_data, app_client, postgres_transactions
461+
):
462+
item = load_test_data("test_item.json")
463+
collection = item["collection"]
464+
postgres_transactions.create_item(
465+
item["collection"], item, request=MockStarletteRequest
466+
)
467+
468+
datetime_range = "2020-01-01T00:00:00.00Z/.."
469+
resp = app_client.get(
470+
f"/collections/{collection}/items", params={"datetime": datetime_range}
471+
)
472+
assert resp.status_code == 200
473+
resp_json = resp.json()
474+
assert len(resp_json["features"]) == 1
475+
476+
datetime_range = "2018-01-01T00:00:00.00Z/2019-01-01T00:00:00.00Z"
477+
resp = app_client.get(
478+
f"/collections/{collection}/items", params={"datetime": datetime_range}
479+
)
480+
assert resp.status_code == 200
481+
resp_json = resp.json()
482+
assert len(resp_json["features"]) == 0

stac_fastapi/types/stac_fastapi/types/core.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,13 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection:
489489

490490
@abc.abstractmethod
491491
def item_collection(
492-
self, collection_id: str, limit: int = 10, token: str = None, **kwargs
492+
self,
493+
collection_id: str,
494+
bbox: Optional[List[NumType]] = None,
495+
datetime: Optional[Union[str, datetime]] = None,
496+
limit: int = 10,
497+
token: str = None,
498+
**kwargs,
493499
) -> stac_types.ItemCollection:
494500
"""Get all items from a specific collection.
495501
@@ -684,7 +690,13 @@ async def get_collection(
684690

685691
@abc.abstractmethod
686692
async def item_collection(
687-
self, collection_id: str, limit: int = 10, token: str = None, **kwargs
693+
self,
694+
collection_id: str,
695+
bbox: Optional[List[NumType]] = None,
696+
datetime: Optional[Union[str, datetime]] = None,
697+
limit: int = 10,
698+
token: str = None,
699+
**kwargs,
688700
) -> stac_types.ItemCollection:
689701
"""Get all items from a specific collection.
690702

0 commit comments

Comments
 (0)