Skip to content

Commit d2fcf13

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 c535aca commit d2fcf13

File tree

16 files changed

+332
-76
lines changed

16 files changed

+332
-76
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
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/api/tests/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_build_api_with_route_dependencies(self):
5555
{"path": "/collections", "method": "PUT"},
5656
{"path": "/collections/{collectionId}", "method": "DELETE"},
5757
{"path": "/collections/{collectionId}/items", "method": "POST"},
58-
{"path": "/collections/{collectionId}/items", "method": "PUT"},
58+
{"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"},
5959
{"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
6060
]
6161
dependencies = [Depends(must_be_bob)]
@@ -68,7 +68,7 @@ def test_add_route_dependencies_after_building_api(self):
6868
{"path": "/collections", "method": "PUT"},
6969
{"path": "/collections/{collectionId}", "method": "DELETE"},
7070
{"path": "/collections/{collectionId}/items", "method": "POST"},
71-
{"path": "/collections/{collectionId}/items", "method": "PUT"},
71+
{"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"},
7272
{"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
7373
]
7474
api = self._build_api()

stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Callable, List, Optional, Type, Union
33

44
import attr
5-
from fastapi import APIRouter, FastAPI
5+
from fastapi import APIRouter, Body, FastAPI
66
from pydantic import BaseModel
77
from stac_pydantic import Collection, Item
88
from starlette.responses import JSONResponse, Response
@@ -15,6 +15,20 @@
1515
from stac_fastapi.types.extension import ApiExtension
1616

1717

18+
@attr.s
19+
class PostItem(CollectionUri):
20+
"""Create Item."""
21+
22+
item: stac_types.Item = attr.ib(default=Body())
23+
24+
25+
@attr.s
26+
class PutItem(ItemUri):
27+
"""Update Item."""
28+
29+
item: stac_types.Item = attr.ib(default=Body())
30+
31+
1832
@attr.s
1933
class TransactionExtension(ApiExtension):
2034
"""Transaction Extension.
@@ -77,20 +91,20 @@ def register_create_item(self):
7791
response_model_exclude_unset=True,
7892
response_model_exclude_none=True,
7993
methods=["POST"],
80-
endpoint=self._create_endpoint(self.client.create_item, stac_types.Item),
94+
endpoint=self._create_endpoint(self.client.create_item, PostItem),
8195
)
8296

8397
def register_update_item(self):
8498
"""Register update item endpoint (PUT /collections/{collection_id}/items)."""
8599
self.router.add_api_route(
86100
name="Update Item",
87-
path="/collections/{collection_id}/items",
101+
path="/collections/{collection_id}/items/{item_id}",
88102
response_model=Item if self.settings.enable_response_models else None,
89103
response_class=self.response_class,
90104
response_model_exclude_unset=True,
91105
response_model_exclude_none=True,
92106
methods=["PUT"],
93-
endpoint=self._create_endpoint(self.client.update_item, stac_types.Item),
107+
endpoint=self._create_endpoint(self.client.update_item, PutItem),
94108
)
95109

96110
def register_delete_item(self):

stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py

Lines changed: 23 additions & 2 deletions
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)

stac_fastapi/pgstac/tests/api/test_api.py

Lines changed: 1 addition & 1 deletion
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

stac_fastapi/pgstac/tests/clients/test_postgres.py

Lines changed: 3 additions & 1 deletion
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}")

stac_fastapi/pgstac/tests/conftest.py

Lines changed: 4 additions & 2 deletions
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

stac_fastapi/pgstac/tests/resources/test_item.py

Lines changed: 60 additions & 10 deletions
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)