Skip to content

Commit

Permalink
Merge branch 'master' into add-nginx-container
Browse files Browse the repository at this point in the history
  • Loading branch information
gadomski authored Feb 15, 2023
2 parents 90ad52c + edc97e9 commit 7d10a47
Show file tree
Hide file tree
Showing 12 changed files with 220 additions and 31 deletions.
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
### Changed

* Updated CI to test against [pgstac v0.6.12](https://github.com/stac-utils/pgstac/releases/tag/v0.6.12) ([#511](https://github.com/stac-utils/stac-fastapi/pull/511))
* Reworked `update_openapi` and added a test for it ([#523](https://github.com/stac-utils/stac-fastapi/pull/523))
* Limit values above 10,000 are now replaced with 10,000 instead of returning a 400 error ([#526](https://github.com/stac-utils/stac-fastapi/pull/526))

### Removed

Expand All @@ -22,6 +24,12 @@
* `self` link rel for `/collections/{c_id}/items` ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
* Media type of the item collection endpoint ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
* Manually exclude non-truthy optional values from sqlalchemy serialization of Collections ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
* Support `intersects` in GET requests ([#521](https://github.com/stac-utils/stac-fastapi/pull/521))
* Deleting items that had repeated ids in other collections ([#520](https://github.com/stac-utils/stac-fastapi/pull/520))

### Deprecated

* Deprecated `VndOaiResponse` and `config_openapi`, will be removed in v3.0 ([#523](https://github.com/stac-utils/stac-fastapi/pull/523))

## [2.4.3] - 2022-11-25

Expand Down
60 changes: 40 additions & 20 deletions stac_fastapi/api/stac_fastapi/api/openapi.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""openapi."""
import warnings

from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.responses import JSONResponse, Response
from starlette.routing import Route, request_response

from stac_fastapi.api.config import ApiExtensions
from stac_fastapi.types.config import ApiSettings
Expand All @@ -13,37 +16,54 @@ class VndOaiResponse(JSONResponse):

media_type = "application/vnd.oai.openapi+json;version=3.0"

def __init__(self, *args, **kwargs):
"""Init function with deprecation warning."""
warnings.warn(
"VndOaiResponse is deprecated and will be removed in v3.0",
DeprecationWarning,
)
super().__init__(*args, **kwargs)


def update_openapi(app: FastAPI) -> FastAPI:
"""Update OpenAPI response content-type.
This function modifies the openapi route to comply with the STAC API spec's
required content-type response header
required content-type response header.
"""
urls = (server_data.get("url") for server_data in app.servers)
server_urls = {url for url in urls if url}

async def openapi(req: Request) -> JSONResponse:
root_path = req.scope.get("root_path", "").rstrip("/")
if root_path not in server_urls:
if root_path and app.root_path_in_servers:
app.servers.insert(0, {"url": root_path})
server_urls.add(root_path)
return VndOaiResponse(app.openapi())

# Remove the default openapi route
app.router.routes = list(
filter(lambda r: r.path != app.openapi_url, app.router.routes)
# Find the route for the openapi_url in the app
openapi_route: Route = next(
route for route in app.router.routes if route.path == app.openapi_url
)
# Add the updated openapi route
app.add_route(app.openapi_url, openapi, include_in_schema=False)
# Store the old endpoint function so we can call it from the patched function
old_endpoint = openapi_route.endpoint

# Create a patched endpoint function that modifies the content type of the response
async def patched_openapi_endpoint(req: Request) -> Response:
# Get the response from the old endpoint function
response: JSONResponse = await old_endpoint(req)
# Update the content type header in place
response.headers[
"content-type"
] = "application/vnd.oai.openapi+json;version=3.0"
# Return the updated response
return response

# When a Route is accessed the `handle` function calls `self.app`. Which is
# the endpoint function wrapped with `request_response`. So we need to wrap
# our patched function and replace the existing app with it.
openapi_route.app = request_response(patched_openapi_endpoint)

# return the patched app
return app


# TODO: Remove or fix, this is currently unused
# and calls a missing method on ApiSettings
def config_openapi(app: FastAPI, settings: ApiSettings):
"""Config openapi."""
warnings.warn(
"config_openapi is deprecated and will be removed in v3.0",
DeprecationWarning,
)

def custom_openapi():
"""Config openapi."""
Expand Down
9 changes: 9 additions & 0 deletions stac_fastapi/api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ def _assert_dependency_applied(api, routes):
), "Authenticated requests should be accepted"
assert response.json() == "dummy response"

def test_openapi_content_type(self):
api = self._build_api()
with TestClient(api.app) as client:
response = client.get(api.settings.openapi_url)
assert (
response.headers["content-type"]
== "application/vnd.oai.openapi+json;version=3.0"
)

def test_build_api_with_route_dependencies(self):
routes = [
{"path": "/collections", "method": "POST"},
Expand Down
4 changes: 4 additions & 0 deletions stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ async def get_search(
sortby: Optional[str] = None,
filter: Optional[str] = None,
filter_lang: Optional[str] = None,
intersects: Optional[str] = None,
**kwargs,
) -> ItemCollection:
"""Cross catalog search (GET).
Expand Down Expand Up @@ -389,6 +390,9 @@ async def get_search(
if datetime:
base_args["datetime"] = datetime

if intersects:
base_args["intersects"] = orjson.loads(unquote_plus(intersects))

if sortby:
# https://github.com/radiantearth/stac-spec/tree/master/api-spec/extensions/sort#http-get-or-post-form
sort_param = []
Expand Down
12 changes: 10 additions & 2 deletions stac_fastapi/pgstac/stac_fastapi/pgstac/db.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Database connection handling."""

import json
from typing import Dict, Union
from contextlib import contextmanager
from typing import Dict, Generator, Union

import attr
import orjson
Expand Down Expand Up @@ -61,7 +62,7 @@ async def dbfunc(pool: pool, func: str, arg: Union[str, Dict]):
arg -- the argument to the PostgreSQL function as either a string
or a dict that will be converted into jsonb
"""
try:
with translate_pgstac_errors():
if isinstance(arg, str):
async with pool.acquire() as conn:
q, p = render(
Expand All @@ -80,6 +81,13 @@ async def dbfunc(pool: pool, func: str, arg: Union[str, Dict]):
item=json.dumps(arg),
)
return await conn.fetchval(q, *p)


@contextmanager
def translate_pgstac_errors() -> Generator[None, None, None]:
"""Context manager that translates pgstac errors into FastAPI errors."""
try:
yield
except exceptions.UniqueViolationError as e:
raise ConflictError from e
except exceptions.NoDataFoundError as e:
Expand Down
14 changes: 11 additions & 3 deletions stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
from typing import Optional, Union

import attr
from buildpg import render
from fastapi import HTTPException
from starlette.responses import JSONResponse, Response

from stac_fastapi.extensions.third_party.bulk_transactions import (
AsyncBaseBulkTransactionsClient,
Items,
)
from stac_fastapi.pgstac.db import dbfunc
from stac_fastapi.pgstac.db import dbfunc, translate_pgstac_errors
from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks
from stac_fastapi.types import stac as stac_types
from stac_fastapi.types.core import AsyncBaseTransactionsClient
Expand Down Expand Up @@ -98,12 +99,19 @@ async def update_collection(
return stac_types.Collection(**collection)

async def delete_item(
self, item_id: str, **kwargs
self, item_id: str, collection_id: str, **kwargs
) -> Optional[Union[stac_types.Item, Response]]:
"""Delete item."""
request = kwargs["request"]
pool = request.app.state.writepool
await dbfunc(pool, "delete_item", item_id)
async with pool.acquire() as conn:
q, p = render(
"SELECT * FROM delete_item(:item::text, :collection::text);",
item=item_id,
collection=collection_id,
)
with translate_pgstac_errors():
await conn.fetchval(q, *p)
return JSONResponse({"deleted item": item_id})

async def delete_collection(
Expand Down
66 changes: 65 additions & 1 deletion stac_fastapi/pgstac/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import orjson
import pytest
from pystac import Collection, Extent, Item, SpatialExtent, TemporalExtent

STAC_CORE_ROUTES = [
"GET /",
Expand All @@ -24,6 +25,24 @@
"PUT /collections/{collection_id}/items/{item_id}",
]

GLOBAL_BBOX = [-180.0, -90.0, 180.0, 90.0]
GLOBAL_GEOMETRY = {
"type": "Polygon",
"coordinates": (
(
(180.0, -90.0),
(180.0, 90.0),
(-180.0, 90.0),
(-180.0, -90.0),
(180.0, -90.0),
),
),
}
DEFAULT_EXTENT = Extent(
SpatialExtent(GLOBAL_BBOX),
TemporalExtent([[datetime.now(), None]]),
)


async def test_post_search_content_type(app_client):
params = {"limit": 1}
Expand Down Expand Up @@ -183,7 +202,7 @@ async def test_app_query_extension_limit_gt10000(

params = {"limit": 10001}
resp = await app_client.post("/search", json=params)
assert resp.status_code == 400
assert resp.status_code == 200


async def test_app_query_extension_gt(load_test_data, app_client, load_test_collection):
Expand Down Expand Up @@ -310,6 +329,15 @@ async def test_search_point_intersects(
resp = await app_client.post(f"/collections/{coll.id}/items", json=item)
assert resp.status_code == 200

new_coordinates = list()
for coordinate in item["geometry"]["coordinates"][0]:
new_coordinates.append([coordinate[0] * -1, coordinate[1] * -1])
item["id"] = "test-item-other-hemispheres"
item["geometry"]["coordinates"] = [new_coordinates]
item["bbox"] = list(value * -1 for value in item["bbox"])
resp = await app_client.post(f"/collections/{coll.id}/items", json=item)
assert resp.status_code == 200

point = [150.04, -33.14]
intersects = {"type": "Point", "coordinates": point}

Expand All @@ -322,6 +350,12 @@ async def test_search_point_intersects(
resp_json = resp.json()
assert len(resp_json["features"]) == 1

params["intersects"] = orjson.dumps(params["intersects"]).decode("utf-8")
resp = await app_client.get("/search", params=params)
assert resp.status_code == 200
resp_json = resp.json()
assert len(resp_json["features"]) == 1


async def test_search_line_string_intersects(
load_test_data, app_client, load_test_collection
Expand Down Expand Up @@ -513,3 +547,33 @@ async def test_bad_collection_queryables(
):
resp = await app_client.get("/collections/bad-collection/queryables")
assert resp.status_code == 404


async def test_deleting_items_with_identical_ids(app_client):
collection_a = Collection("collection-a", "The first collection", DEFAULT_EXTENT)
collection_b = Collection("collection-b", "The second collection", DEFAULT_EXTENT)
item = Item("the-item", GLOBAL_GEOMETRY, GLOBAL_BBOX, datetime.now(), {})

for collection in (collection_a, collection_b):
response = await app_client.post(
"/collections", json=collection.to_dict(include_self_link=False)
)
assert response.status_code == 200
item_as_dict = item.to_dict(include_self_link=False)
item_as_dict["collection"] = collection.id
response = await app_client.post(
f"/collections/{collection.id}/items", json=item_as_dict
)
assert response.status_code == 200
response = await app_client.get(f"/collections/{collection.id}/items")
assert response.status_code == 200, response.json()
assert len(response.json()["features"]) == 1

for collection in (collection_a, collection_b):
response = await app_client.delete(
f"/collections/{collection.id}/items/{item.id}"
)
assert response.status_code == 200, response.json()
response = await app_client.get(f"/collections/{collection.id}/items")
assert response.status_code == 200, response.json()
assert not response.json()["features"]
4 changes: 4 additions & 0 deletions stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def get_search(
token: Optional[str] = None,
fields: Optional[List[str]] = None,
sortby: Optional[str] = None,
intersects: Optional[str] = None,
**kwargs,
) -> ItemCollection:
"""GET search catalog."""
Expand All @@ -265,6 +266,9 @@ def get_search(
if datetime:
base_args["datetime"] = datetime

if intersects:
base_args["intersects"] = json.loads(unquote_plus(intersects))

if sortby:
# https://github.com/radiantearth/stac-spec/tree/master/api-spec/extensions/sort#http-get-or-post-form
sort_param = []
Expand Down
18 changes: 17 additions & 1 deletion stac_fastapi/sqlalchemy/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def test_app_query_extension_limit_gt10000(

params = {"limit": 10001}
resp = app_client.post("/search", json=params)
assert resp.status_code == 400
assert resp.status_code == 200


def test_app_query_extension_limit_10000(
Expand Down Expand Up @@ -276,6 +276,16 @@ def test_search_point_intersects(load_test_data, app_client, postgres_transactio
item["collection"], item, request=MockStarletteRequest
)

new_coordinates = list()
for coordinate in item["geometry"]["coordinates"][0]:
new_coordinates.append([coordinate[0] * -1, coordinate[1] * -1])
item["id"] = "test-item-other-hemispheres"
item["geometry"]["coordinates"] = [new_coordinates]
item["bbox"] = list(value * -1 for value in item["bbox"])
postgres_transactions.create_item(
item["collection"], item, request=MockStarletteRequest
)

point = [150.04, -33.14]
intersects = {"type": "Point", "coordinates": point}

Expand All @@ -288,6 +298,12 @@ def test_search_point_intersects(load_test_data, app_client, postgres_transactio
resp_json = resp.json()
assert len(resp_json["features"]) == 1

params["intersects"] = orjson.dumps(params["intersects"]).decode("utf-8")
resp = app_client.get("/search", params=params)
assert resp.status_code == 200
resp_json = resp.json()
assert len(resp_json["features"]) == 1


def test_datetime_non_interval(load_test_data, app_client, postgres_transactions):
item = load_test_data("test_item.json")
Expand Down
2 changes: 2 additions & 0 deletions stac_fastapi/types/stac_fastapi/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ def get_search(
token: Optional[str] = None,
fields: Optional[List[str]] = None,
sortby: Optional[str] = None,
intersects: Optional[str] = None,
**kwargs,
) -> stac_types.ItemCollection:
"""Cross catalog search (GET).
Expand Down Expand Up @@ -627,6 +628,7 @@ async def get_search(
token: Optional[str] = None,
fields: Optional[List[str]] = None,
sortby: Optional[str] = None,
intersects: Optional[str] = None,
**kwargs,
) -> stac_types.ItemCollection:
"""Cross catalog search (GET).
Expand Down
Loading

0 comments on commit 7d10a47

Please sign in to comment.