Skip to content

Commit 94ff598

Browse files
authored
Use collection_id path parameter Items Transactions endpoints (#425)
* Add collection_id path parameter and check against Item collection property * Fix unformatted f-strings * Fix Item PUT endpoint per #385 * Update API tests to use new PUT paths * Make equivalent changes to sqlalchemy backend * Add CHANGELOG entry for #425 * Fix failing tests from previous merge * Return 400 for Item id or collection conflicts
1 parent 0a2ba76 commit 94ff598

File tree

8 files changed

+158
-17
lines changed

8 files changed

+158
-17
lines changed

CHANGES.md

+6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
* docker-compose now runs uvicorn with hot-reloading enabled
2323
* Bump version of PGStac to 0.6.2 that includes support for hydrating results in the API backed ([#397](https://github.com/stac-utils/stac-fastapi/pull/397))
2424
* Make item geometry and bbox nullable in sqlalchemy backend. ([#398](https://github.com/stac-utils/stac-fastapi/pull/398))
25+
* Transactions Extension update Item endpoint Item is now `/collections/{collection_id}/items/{item_id}` instead of
26+
`/collections/{collection_id}/items` to align with [STAC API
27+
spec](https://github.com/radiantearth/stac-api-spec/tree/main/ogcapi-features/extensions/transaction#methods) ([#425](https://github.com/stac-utils/stac-fastapi/pull/425))
2528

2629
### Removed
2730
* Remove the unused `router_middleware` function ([#439](https://github.com/stac-utils/stac-fastapi/pull/439))
@@ -36,6 +39,9 @@
3639
* SQLAlchemy backend bulk item insert now works ([#356](https://github.com/stac-utils/stac-fastapi/issues/356))
3740
* PGStac Backend has stricter implementation of Fields Extension syntax ([#397](https://github.com/stac-utils/stac-fastapi/pull/397))
3841
* `/queryables` endpoint now has type `application/schema+json` instead of `application/json` ([#421](https://github.com/stac-utils/stac-fastapi/pull/421))
42+
* Transactions Extension update Item endpoint validates that the `{collection_id}` path parameter matches the Item `"collection"` property
43+
from the request body, if present, and falls back to using the path parameter if no `"collection"` property is found in the body
44+
([#425](https://github.com/stac-utils/stac-fastapi/pull/425))
3945

4046
## [2.3.0]
4147

scripts/ingest_joplin.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ def post_or_put(url: str, data: dict):
1919
"""Post or put data to url."""
2020
r = requests.post(url, json=data)
2121
if r.status_code == 409:
22+
new_url = url if data["type"] == "Collection" else url + f"/{data['id']}"
2223
# Exists, so update
23-
r = requests.put(url, json=data)
24+
r = requests.put(new_url, json=data)
2425
# Unchanged may throw a 404
2526
if not r.status_code == 404:
2627
r.raise_for_status()

stac_fastapi/pgstac/transactions.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional, Union
55

66
import attr
7+
from fastapi import HTTPException
78
from starlette.responses import JSONResponse, Response
89

910
from stac_fastapi.extensions.third_party.bulk_transactions import (
@@ -23,18 +24,38 @@ class TransactionsClient(AsyncBaseTransactionsClient):
2324
"""Transactions extension specific CRUD operations."""
2425

2526
async def create_item(
26-
self, item: stac_types.Item, **kwargs
27+
self, collection_id: str, item: stac_types.Item, **kwargs
2728
) -> Optional[Union[stac_types.Item, Response]]:
2829
"""Create item."""
30+
body_collection_id = item.get("collection")
31+
if body_collection_id is not None and collection_id != body_collection_id:
32+
raise HTTPException(
33+
status_code=400,
34+
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
35+
)
36+
item["collection"] = collection_id
2937
request = kwargs["request"]
3038
pool = request.app.state.writepool
3139
await dbfunc(pool, "create_item", item)
3240
return item
3341

3442
async def update_item(
35-
self, item: stac_types.Item, **kwargs
43+
self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs
3644
) -> Optional[Union[stac_types.Item, Response]]:
3745
"""Update item."""
46+
body_collection_id = item.get("collection")
47+
if body_collection_id is not None and collection_id != body_collection_id:
48+
raise HTTPException(
49+
status_code=400,
50+
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
51+
)
52+
item["collection"] = collection_id
53+
body_item_id = item["id"]
54+
if body_item_id != item_id:
55+
raise HTTPException(
56+
status_code=400,
57+
detail=f"Item ID from path parameter ({item_id}) does not match Item ID from Item ({body_item_id})",
58+
)
3859
request = kwargs["request"]
3960
pool = request.app.state.writepool
4061
await dbfunc(pool, "update_item", item)

testdata/joplin/feature.geojson

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"id": "f2cca2a3-288b-4518-8a3e-a4492bb60b08",
3+
"type": "Feature",
4+
"collection": "joplin",
5+
"links": [],
6+
"geometry": {
7+
"type": "Polygon",
8+
"coordinates": [
9+
[
10+
[
11+
-94.6884155,
12+
37.0595608
13+
],
14+
[
15+
-94.6884155,
16+
37.0332547
17+
],
18+
[
19+
-94.6554565,
20+
37.0332547
21+
],
22+
[
23+
-94.6554565,
24+
37.0595608
25+
],
26+
[
27+
-94.6884155,
28+
37.0595608
29+
]
30+
]
31+
]
32+
},
33+
"properties": {
34+
"proj:epsg": 3857,
35+
"orientation": "nadir",
36+
"height": 2500,
37+
"width": 2500,
38+
"datetime": "2000-02-02T00:00:00Z",
39+
"gsd": 0.5971642834779395
40+
},
41+
"assets": {
42+
"COG": {
43+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
44+
"href": "https://arturo-stac-api-test-data.s3.amazonaws.com/joplin/images/may24C350000e4102500n.tif",
45+
"title": "NOAA STORM COG"
46+
}
47+
},
48+
"bbox": [
49+
-94.6884155,
50+
37.0332547,
51+
-94.6554565,
52+
37.0595608
53+
],
54+
"stac_extensions": [
55+
"https://stac-extensions.github.io/eo/v1.0.0/schema.json",
56+
"https://stac-extensions.github.io/projection/v1.0.0/schema.json"
57+
],
58+
"stac_version": "1.0.0"
59+
}

tests/api/test_api.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"POST /collections",
2020
"POST /collections/{collection_id}/items",
2121
"PUT /collections",
22-
"PUT /collections/{collection_id}/items",
22+
"PUT /collections/{collection_id}/items/{item_id}",
2323
]
2424

2525

tests/clients/test_postgres.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ async def test_update_item(app_client, load_test_collection, load_test_item):
7676

7777
item.properties.description = "Update Test"
7878

79-
resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json())
79+
resp = await app_client.put(
80+
f"/collections/{coll.id}/items/{item.id}", content=item.json()
81+
)
8082
assert resp.status_code == 200
8183

8284
resp = await app_client.get(f"/collections/{coll.id}/items/{item.id}")

tests/conftest.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,10 @@ async def load_test_collection(app_client, load_test_data):
201201

202202
@pytest.fixture
203203
async def load_test_item(app_client, load_test_data, load_test_collection):
204+
coll = load_test_collection
204205
data = load_test_data("test_item.json")
205206
resp = await app_client.post(
206-
"/collections/{coll.id}/items",
207+
f"/collections/{coll.id}/items",
207208
json=data,
208209
)
209210
assert resp.status_code == 200
@@ -223,9 +224,10 @@ async def load_test2_collection(app_client, load_test_data):
223224

224225
@pytest.fixture
225226
async def load_test2_item(app_client, load_test_data, load_test2_collection):
227+
coll = load_test2_collection
226228
data = load_test_data("test2_item.json")
227229
resp = await app_client.post(
228-
"/collections/{coll.id}/items",
230+
f"/collections/{coll.id}/items",
229231
json=data,
230232
)
231233
assert resp.status_code == 200

tests/resources/test_item.py

+60-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
2+
import random
23
import uuid
34
from datetime import timedelta
45
from http.client import HTTP_PORT
6+
from string import ascii_letters
57
from typing import Callable
68
from urllib.parse import parse_qs, urljoin, urlparse
79

@@ -81,6 +83,24 @@ async def test_create_item(app_client, load_test_data: Callable, load_test_colle
8183
assert in_item.dict(exclude={"links"}) == get_item.dict(exclude={"links"})
8284

8385

86+
async def test_create_item_mismatched_collection_id(
87+
app_client, load_test_data: Callable, load_test_collection
88+
):
89+
# If the collection_id path parameter and the Item's "collection" property do not match, a 400 response should
90+
# be returned.
91+
coll = load_test_collection
92+
93+
in_json = load_test_data("test_item.json")
94+
in_json["collection"] = random.choice(ascii_letters)
95+
assert in_json["collection"] != coll.id
96+
97+
resp = await app_client.post(
98+
f"/collections/{coll.id}/items",
99+
json=in_json,
100+
)
101+
assert resp.status_code == 400
102+
103+
84104
async def test_fetches_valid_item(
85105
app_client, load_test_data: Callable, load_test_collection
86106
):
@@ -89,7 +109,7 @@ async def test_fetches_valid_item(
89109
in_json = load_test_data("test_item.json")
90110
in_item = Item.parse_obj(in_json)
91111
resp = await app_client.post(
92-
"/collections/{coll.id}/items",
112+
f"/collections/{coll.id}/items",
93113
json=in_json,
94114
)
95115
assert resp.status_code == 200
@@ -117,7 +137,9 @@ async def test_update_item(
117137

118138
item.properties.description = "Update Test"
119139

120-
resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json())
140+
resp = await app_client.put(
141+
f"/collections/{coll.id}/items/{item.id}", content=item.json()
142+
)
121143
assert resp.status_code == 200
122144

123145
resp = await app_client.get(f"/collections/{coll.id}/items/{item.id}")
@@ -128,6 +150,25 @@ async def test_update_item(
128150
assert get_item.properties.description == "Update Test"
129151

130152

153+
async def test_update_item_mismatched_collection_id(
154+
app_client, load_test_data: Callable, load_test_collection, load_test_item
155+
) -> None:
156+
coll = load_test_collection
157+
158+
in_json = load_test_data("test_item.json")
159+
160+
in_json["collection"] = random.choice(ascii_letters)
161+
assert in_json["collection"] != coll.id
162+
163+
item_id = in_json["id"]
164+
165+
resp = await app_client.put(
166+
f"/collections/{coll.id}/items/{item_id}",
167+
json=in_json,
168+
)
169+
assert resp.status_code == 400
170+
171+
131172
async def test_delete_item(
132173
app_client, load_test_data: Callable, load_test_collection, load_test_item
133174
):
@@ -165,18 +206,17 @@ async def test_get_collection_items(app_client, load_test_collection, load_test_
165206
async def test_create_item_conflict(
166207
app_client, load_test_data: Callable, load_test_collection
167208
):
168-
pass
169-
209+
coll = load_test_collection
170210
in_json = load_test_data("test_item.json")
171211
Item.parse_obj(in_json)
172212
resp = await app_client.post(
173-
"/collections/{coll.id}/items",
213+
f"/collections/{coll.id}/items",
174214
json=in_json,
175215
)
176216
assert resp.status_code == 200
177217

178218
resp = await app_client.post(
179-
"/collections/{coll.id}/items",
219+
f"/collections/{coll.id}/items",
180220
json=in_json,
181221
)
182222
assert resp.status_code == 409
@@ -203,7 +243,10 @@ async def test_create_item_missing_collection(
203243
item["collection"] = None
204244

205245
resp = await app_client.post(f"/collections/{coll.id}/items", json=item)
206-
assert resp.status_code == 424
246+
assert resp.status_code == 200
247+
248+
post_item = resp.json()
249+
assert post_item["collection"] == coll.id
207250

208251

209252
async def test_update_new_item(
@@ -213,7 +256,9 @@ async def test_update_new_item(
213256
item = load_test_item
214257
item.id = "test-updatenewitem"
215258

216-
resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json())
259+
resp = await app_client.put(
260+
f"/collections/{coll.id}/items/{item.id}", content=item.json()
261+
)
217262
assert resp.status_code == 404
218263

219264

@@ -224,8 +269,13 @@ async def test_update_item_missing_collection(
224269
item = load_test_item
225270
item.collection = None
226271

227-
resp = await app_client.put(f"/collections/{coll.id}/items", content=item.json())
228-
assert resp.status_code == 424
272+
resp = await app_client.put(
273+
f"/collections/{coll.id}/items/{item.id}", content=item.json()
274+
)
275+
assert resp.status_code == 200
276+
277+
put_item = resp.json()
278+
assert put_item["collection"] == coll.id
229279

230280

231281
async def test_pagination(app_client, load_test_data, load_test_collection):

0 commit comments

Comments
 (0)