Skip to content

Commit 862f59e

Browse files
mmcfarlandgadomski
andauthored
Support creating ItemCollections in Transaction Extension (#35)
* Align dev container app path with image The image copies the root project to /app and matching that through docker-compose allows host file updates to be copied without rebuilding the image. * Implement POST ItemCollection for Transaction Ext Updates to the transaction spec indicate POST against items should allow ItemCollection. * Add consistent validation for item and collection In the transaction extension, Items and Collection can't have mismatched ids from the path, but should have the path collection id applied if it is missing. Ids for both are also not allowed to be a "percent encoded" value per RFC 3986. * Add transaction tests * Changelog * Align respose type for create Item/ItemCollection The spec leaves the return type for Item creation open to the implementation. This change unifies the response of both Item/ItemCollection POST requests to return an empty response with a Location header for the newly created single Item, in that case. * Allow override of valid item/collection ids Use a setting value instead of a constant so that IDs could be set per instance. * Walk back some response unification After realizing the extent of the breaking change resulting from a unified response between Item/ItemCollection Tx endpoint, restoring the original behavior. * Upgdate dev pgstac version * isort lint * deps: soft pin the stac-fastapi versions --------- Co-authored-by: Pete Gadomski <pete.gadomski@gmail.com>
1 parent 2744ba2 commit 862f59e

File tree

7 files changed

+286
-36
lines changed

7 files changed

+286
-36
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ As a part of this release, this repository was extracted from the main
1010
### Added
1111

1212
* Ability to customize the database connection ([#22](https://github.com/stac-utils/stac-fastapi-pgstac/pull/22))
13+
* Ability to add ItemCollections through the Transaction API, with more validation ([#35](https://github.com/stac-utils/stac-fastapi-pgstac/pull/35))
1314

1415
### Changed
1516

docker-compose.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,14 @@ services:
2626
ports:
2727
- "8082:8082"
2828
volumes:
29-
- ./stac_fastapi:/app/stac_fastapi
30-
- ./scripts:/app/scripts
29+
- .:/app
3130
depends_on:
3231
- database
3332
command: bash -c "./scripts/wait-for-it.sh database:5432 && python -m stac_fastapi.pgstac.app"
3433

3534
database:
3635
container_name: stac-db
37-
image: ghcr.io/stac-utils/pgstac:v0.7.1
36+
image: ghcr.io/stac-utils/pgstac:v0.7.6
3837
environment:
3938
- POSTGRES_USER=username
4039
- POSTGRES_PASSWORD=password

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
"orjson",
1111
"pydantic[dotenv]",
1212
"stac_pydantic==2.0.*",
13-
"stac-fastapi.types",
14-
"stac-fastapi.api",
15-
"stac-fastapi.extensions",
13+
"stac-fastapi.types~=2.4.7",
14+
"stac-fastapi.api~=2.4.7",
15+
"stac-fastapi.extensions~=2.4.7",
1616
"asyncpg",
1717
"buildpg",
1818
"brotli_asgi",

stac_fastapi/pgstac/config.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Postgres API configuration."""
22

3-
from typing import Type
3+
from typing import List, Type
44
from urllib.parse import quote
55

66
from stac_fastapi.types.config import ApiSettings
@@ -10,6 +10,27 @@
1010
DefaultBaseItemCache,
1111
)
1212

13+
DEFAULT_INVALID_ID_CHARS = [
14+
":",
15+
"/",
16+
"?",
17+
"#",
18+
"[",
19+
"]",
20+
"@",
21+
"!",
22+
"$",
23+
"&",
24+
"'",
25+
"(",
26+
")",
27+
"*",
28+
"+",
29+
",",
30+
";",
31+
"=",
32+
]
33+
1334

1435
class Settings(ApiSettings):
1536
"""Postgres-specific API settings.
@@ -22,6 +43,7 @@ class Settings(ApiSettings):
2243
postgres_port: database port.
2344
postgres_dbname: database name.
2445
use_api_hydrate: perform hydration of stac items within stac-fastapi.
46+
invalid_id_chars: list of characters that are not allowed in item or collection ids.
2547
"""
2648

2749
postgres_user: str
@@ -38,6 +60,7 @@ class Settings(ApiSettings):
3860

3961
use_api_hydrate: bool = False
4062
base_item_cache: Type[BaseItemCache] = DefaultBaseItemCache
63+
invalid_id_chars: List[str] = DEFAULT_INVALID_ID_CHARS
4164

4265
testing: bool = False
4366

stac_fastapi/pgstac/transactions.py

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""transactions extension client."""
22

33
import logging
4+
import re
45
from typing import Optional, Union
56

67
import attr
@@ -14,6 +15,7 @@
1415
from stac_fastapi.types.core import AsyncBaseTransactionsClient
1516
from starlette.responses import JSONResponse, Response
1617

18+
from stac_fastapi.pgstac.config import Settings
1719
from stac_fastapi.pgstac.db import dbfunc
1820
from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks
1921

@@ -25,25 +27,83 @@
2527
class TransactionsClient(AsyncBaseTransactionsClient):
2628
"""Transactions extension specific CRUD operations."""
2729

28-
async def create_item(
29-
self, collection_id: str, item: stac_types.Item, request: Request, **kwargs
30-
) -> Optional[Union[stac_types.Item, Response]]:
31-
"""Create item."""
30+
def _validate_id(self, id: str, settings: Settings) -> bool:
31+
invalid_chars = settings.invalid_id_chars
32+
id_regex = "[" + "".join(re.escape(char) for char in invalid_chars) + "]"
33+
34+
if bool(re.search(id_regex, id)):
35+
raise HTTPException(
36+
status_code=400,
37+
detail=f"ID ({id}) cannot contain the following characters: {' '.join(invalid_chars)}",
38+
)
39+
40+
def _validate_collection(self, request: Request, collection: stac_types.Collection):
41+
self._validate_id(collection["id"], request.app.state.settings)
42+
43+
def _validate_item(
44+
self,
45+
request: Request,
46+
item: stac_types.Item,
47+
collection_id: str,
48+
expected_item_id: Optional[str] = None,
49+
) -> None:
50+
"""Validate item."""
3251
body_collection_id = item.get("collection")
52+
body_item_id = item.get("id")
53+
54+
self._validate_id(body_item_id, request.app.state.settings)
55+
3356
if body_collection_id is not None and collection_id != body_collection_id:
3457
raise HTTPException(
3558
status_code=400,
3659
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
3760
)
38-
item["collection"] = collection_id
39-
async with request.app.state.get_connection(request, "w") as conn:
40-
await dbfunc(conn, "create_item", item)
41-
item["links"] = await ItemLinks(
42-
collection_id=collection_id,
43-
item_id=item["id"],
44-
request=request,
45-
).get_links(extra_links=item.get("links"))
46-
return stac_types.Item(**item)
61+
62+
if expected_item_id is not None and expected_item_id != body_item_id:
63+
raise HTTPException(
64+
status_code=400,
65+
detail=f"Item ID from path parameter ({expected_item_id}) does not match Item ID from Item ({body_item_id})",
66+
)
67+
68+
async def create_item(
69+
self,
70+
collection_id: str,
71+
item: Union[stac_types.Item, stac_types.ItemCollection],
72+
request: Request,
73+
**kwargs,
74+
) -> Optional[Union[stac_types.Item, Response]]:
75+
"""Create item."""
76+
if item["type"] == "FeatureCollection":
77+
valid_items = []
78+
for item in item["features"]:
79+
self._validate_item(request, item, collection_id)
80+
item["collection"] = collection_id
81+
valid_items.append(item)
82+
83+
async with request.app.state.get_connection(request, "w") as conn:
84+
await dbfunc(conn, "create_items", valid_items)
85+
86+
return Response(status_code=201)
87+
88+
elif item["type"] == "Feature":
89+
self._validate_item(request, item, collection_id)
90+
item["collection"] = collection_id
91+
92+
async with request.app.state.get_connection(request, "w") as conn:
93+
await dbfunc(conn, "create_item", item)
94+
95+
item["links"] = await ItemLinks(
96+
collection_id=collection_id,
97+
item_id=item["id"],
98+
request=request,
99+
).get_links(extra_links=item.get("links"))
100+
101+
return stac_types.Item(**item)
102+
else:
103+
raise HTTPException(
104+
status_code=400,
105+
detail=f"Item body type must be 'Feature' or 'FeatureCollection', not {item['type']}",
106+
)
47107

48108
async def update_item(
49109
self,
@@ -54,32 +114,25 @@ async def update_item(
54114
**kwargs,
55115
) -> Optional[Union[stac_types.Item, Response]]:
56116
"""Update item."""
57-
body_collection_id = item.get("collection")
58-
if body_collection_id is not None and collection_id != body_collection_id:
59-
raise HTTPException(
60-
status_code=400,
61-
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
62-
)
117+
self._validate_item(request, item, collection_id, item_id)
63118
item["collection"] = collection_id
64-
body_item_id = item["id"]
65-
if body_item_id != item_id:
66-
raise HTTPException(
67-
status_code=400,
68-
detail=f"Item ID from path parameter ({item_id}) does not match Item ID from Item ({body_item_id})",
69-
)
119+
70120
async with request.app.state.get_connection(request, "w") as conn:
71121
await dbfunc(conn, "update_item", item)
122+
72123
item["links"] = await ItemLinks(
73124
collection_id=collection_id,
74125
item_id=item["id"],
75126
request=request,
76127
).get_links(extra_links=item.get("links"))
128+
77129
return stac_types.Item(**item)
78130

79131
async def create_collection(
80132
self, collection: stac_types.Collection, request: Request, **kwargs
81133
) -> Optional[Union[stac_types.Collection, Response]]:
82134
"""Create collection."""
135+
self._validate_collection(request, collection)
83136
async with request.app.state.get_connection(request, "w") as conn:
84137
await dbfunc(conn, "create_collection", collection)
85138
collection["links"] = await CollectionLinks(

0 commit comments

Comments
 (0)