Skip to content

Commit

Permalink
Support creating ItemCollections in Transaction Extension (#35)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
mmcfarland and gadomski committed May 17, 2023
1 parent 2744ba2 commit 862f59e
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 36 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ As a part of this release, this repository was extracted from the main
### Added

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

### Changed

Expand Down
5 changes: 2 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@ services:
ports:
- "8082:8082"
volumes:
- ./stac_fastapi:/app/stac_fastapi
- ./scripts:/app/scripts
- .:/app
depends_on:
- database
command: bash -c "./scripts/wait-for-it.sh database:5432 && python -m stac_fastapi.pgstac.app"

database:
container_name: stac-db
image: ghcr.io/stac-utils/pgstac:v0.7.1
image: ghcr.io/stac-utils/pgstac:v0.7.6
environment:
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
Expand Down
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"orjson",
"pydantic[dotenv]",
"stac_pydantic==2.0.*",
"stac-fastapi.types",
"stac-fastapi.api",
"stac-fastapi.extensions",
"stac-fastapi.types~=2.4.7",
"stac-fastapi.api~=2.4.7",
"stac-fastapi.extensions~=2.4.7",
"asyncpg",
"buildpg",
"brotli_asgi",
Expand Down
25 changes: 24 additions & 1 deletion stac_fastapi/pgstac/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Postgres API configuration."""

from typing import Type
from typing import List, Type
from urllib.parse import quote

from stac_fastapi.types.config import ApiSettings
Expand All @@ -10,6 +10,27 @@
DefaultBaseItemCache,
)

DEFAULT_INVALID_ID_CHARS = [
":",
"/",
"?",
"#",
"[",
"]",
"@",
"!",
"$",
"&",
"'",
"(",
")",
"*",
"+",
",",
";",
"=",
]


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

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

use_api_hydrate: bool = False
base_item_cache: Type[BaseItemCache] = DefaultBaseItemCache
invalid_id_chars: List[str] = DEFAULT_INVALID_ID_CHARS

testing: bool = False

Expand Down
103 changes: 78 additions & 25 deletions stac_fastapi/pgstac/transactions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""transactions extension client."""

import logging
import re
from typing import Optional, Union

import attr
Expand All @@ -14,6 +15,7 @@
from stac_fastapi.types.core import AsyncBaseTransactionsClient
from starlette.responses import JSONResponse, Response

from stac_fastapi.pgstac.config import Settings
from stac_fastapi.pgstac.db import dbfunc
from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks

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

async def create_item(
self, collection_id: str, item: stac_types.Item, request: Request, **kwargs
) -> Optional[Union[stac_types.Item, Response]]:
"""Create item."""
def _validate_id(self, id: str, settings: Settings) -> bool:
invalid_chars = settings.invalid_id_chars
id_regex = "[" + "".join(re.escape(char) for char in invalid_chars) + "]"

if bool(re.search(id_regex, id)):
raise HTTPException(
status_code=400,
detail=f"ID ({id}) cannot contain the following characters: {' '.join(invalid_chars)}",
)

def _validate_collection(self, request: Request, collection: stac_types.Collection):
self._validate_id(collection["id"], request.app.state.settings)

def _validate_item(
self,
request: Request,
item: stac_types.Item,
collection_id: str,
expected_item_id: Optional[str] = None,
) -> None:
"""Validate item."""
body_collection_id = item.get("collection")
body_item_id = item.get("id")

self._validate_id(body_item_id, request.app.state.settings)

if body_collection_id is not None and collection_id != body_collection_id:
raise HTTPException(
status_code=400,
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
)
item["collection"] = collection_id
async with request.app.state.get_connection(request, "w") as conn:
await dbfunc(conn, "create_item", item)
item["links"] = await ItemLinks(
collection_id=collection_id,
item_id=item["id"],
request=request,
).get_links(extra_links=item.get("links"))
return stac_types.Item(**item)

if expected_item_id is not None and expected_item_id != body_item_id:
raise HTTPException(
status_code=400,
detail=f"Item ID from path parameter ({expected_item_id}) does not match Item ID from Item ({body_item_id})",
)

async def create_item(
self,
collection_id: str,
item: Union[stac_types.Item, stac_types.ItemCollection],
request: Request,
**kwargs,
) -> Optional[Union[stac_types.Item, Response]]:
"""Create item."""
if item["type"] == "FeatureCollection":
valid_items = []
for item in item["features"]:
self._validate_item(request, item, collection_id)
item["collection"] = collection_id
valid_items.append(item)

async with request.app.state.get_connection(request, "w") as conn:
await dbfunc(conn, "create_items", valid_items)

return Response(status_code=201)

elif item["type"] == "Feature":
self._validate_item(request, item, collection_id)
item["collection"] = collection_id

async with request.app.state.get_connection(request, "w") as conn:
await dbfunc(conn, "create_item", item)

item["links"] = await ItemLinks(
collection_id=collection_id,
item_id=item["id"],
request=request,
).get_links(extra_links=item.get("links"))

return stac_types.Item(**item)
else:
raise HTTPException(
status_code=400,
detail=f"Item body type must be 'Feature' or 'FeatureCollection', not {item['type']}",
)

async def update_item(
self,
Expand All @@ -54,32 +114,25 @@ async def update_item(
**kwargs,
) -> Optional[Union[stac_types.Item, Response]]:
"""Update item."""
body_collection_id = item.get("collection")
if body_collection_id is not None and collection_id != body_collection_id:
raise HTTPException(
status_code=400,
detail=f"Collection ID from path parameter ({collection_id}) does not match Collection ID from Item ({body_collection_id})",
)
self._validate_item(request, item, collection_id, item_id)
item["collection"] = collection_id
body_item_id = item["id"]
if body_item_id != item_id:
raise HTTPException(
status_code=400,
detail=f"Item ID from path parameter ({item_id}) does not match Item ID from Item ({body_item_id})",
)

async with request.app.state.get_connection(request, "w") as conn:
await dbfunc(conn, "update_item", item)

item["links"] = await ItemLinks(
collection_id=collection_id,
item_id=item["id"],
request=request,
).get_links(extra_links=item.get("links"))

return stac_types.Item(**item)

async def create_collection(
self, collection: stac_types.Collection, request: Request, **kwargs
) -> Optional[Union[stac_types.Collection, Response]]:
"""Create collection."""
self._validate_collection(request, collection)
async with request.app.state.get_connection(request, "w") as conn:
await dbfunc(conn, "create_collection", collection)
collection["links"] = await CollectionLinks(
Expand Down
Loading

0 comments on commit 862f59e

Please sign in to comment.