Skip to content

Commit 7d10a47

Browse files
authored
Merge branch 'master' into add-nginx-container
2 parents 90ad52c + edc97e9 commit 7d10a47

File tree

12 files changed

+220
-31
lines changed

12 files changed

+220
-31
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
### Changed
1111

1212
* 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))
13+
* Reworked `update_openapi` and added a test for it ([#523](https://github.com/stac-utils/stac-fastapi/pull/523))
14+
* 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))
1315

1416
### Removed
1517

@@ -22,6 +24,12 @@
2224
* `self` link rel for `/collections/{c_id}/items` ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
2325
* Media type of the item collection endpoint ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
2426
* Manually exclude non-truthy optional values from sqlalchemy serialization of Collections ([#508](https://github.com/stac-utils/stac-fastapi/pull/508))
27+
* Support `intersects` in GET requests ([#521](https://github.com/stac-utils/stac-fastapi/pull/521))
28+
* Deleting items that had repeated ids in other collections ([#520](https://github.com/stac-utils/stac-fastapi/pull/520))
29+
30+
### Deprecated
31+
32+
* Deprecated `VndOaiResponse` and `config_openapi`, will be removed in v3.0 ([#523](https://github.com/stac-utils/stac-fastapi/pull/523))
2533

2634
## [2.4.3] - 2022-11-25
2735

stac_fastapi/api/stac_fastapi/api/openapi.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""openapi."""
2+
import warnings
3+
24
from fastapi import FastAPI
35
from fastapi.openapi.utils import get_openapi
46
from starlette.requests import Request
5-
from starlette.responses import JSONResponse
7+
from starlette.responses import JSONResponse, Response
8+
from starlette.routing import Route, request_response
69

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

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

19+
def __init__(self, *args, **kwargs):
20+
"""Init function with deprecation warning."""
21+
warnings.warn(
22+
"VndOaiResponse is deprecated and will be removed in v3.0",
23+
DeprecationWarning,
24+
)
25+
super().__init__(*args, **kwargs)
26+
1627

1728
def update_openapi(app: FastAPI) -> FastAPI:
1829
"""Update OpenAPI response content-type.
1930
2031
This function modifies the openapi route to comply with the STAC API spec's
21-
required content-type response header
32+
required content-type response header.
2233
"""
23-
urls = (server_data.get("url") for server_data in app.servers)
24-
server_urls = {url for url in urls if url}
25-
26-
async def openapi(req: Request) -> JSONResponse:
27-
root_path = req.scope.get("root_path", "").rstrip("/")
28-
if root_path not in server_urls:
29-
if root_path and app.root_path_in_servers:
30-
app.servers.insert(0, {"url": root_path})
31-
server_urls.add(root_path)
32-
return VndOaiResponse(app.openapi())
33-
34-
# Remove the default openapi route
35-
app.router.routes = list(
36-
filter(lambda r: r.path != app.openapi_url, app.router.routes)
34+
# Find the route for the openapi_url in the app
35+
openapi_route: Route = next(
36+
route for route in app.router.routes if route.path == app.openapi_url
3737
)
38-
# Add the updated openapi route
39-
app.add_route(app.openapi_url, openapi, include_in_schema=False)
38+
# Store the old endpoint function so we can call it from the patched function
39+
old_endpoint = openapi_route.endpoint
40+
41+
# Create a patched endpoint function that modifies the content type of the response
42+
async def patched_openapi_endpoint(req: Request) -> Response:
43+
# Get the response from the old endpoint function
44+
response: JSONResponse = await old_endpoint(req)
45+
# Update the content type header in place
46+
response.headers[
47+
"content-type"
48+
] = "application/vnd.oai.openapi+json;version=3.0"
49+
# Return the updated response
50+
return response
51+
52+
# When a Route is accessed the `handle` function calls `self.app`. Which is
53+
# the endpoint function wrapped with `request_response`. So we need to wrap
54+
# our patched function and replace the existing app with it.
55+
openapi_route.app = request_response(patched_openapi_endpoint)
56+
57+
# return the patched app
4058
return app
4159

4260

43-
# TODO: Remove or fix, this is currently unused
44-
# and calls a missing method on ApiSettings
4561
def config_openapi(app: FastAPI, settings: ApiSettings):
4662
"""Config openapi."""
63+
warnings.warn(
64+
"config_openapi is deprecated and will be removed in v3.0",
65+
DeprecationWarning,
66+
)
4767

4868
def custom_openapi():
4969
"""Config openapi."""

stac_fastapi/api/tests/test_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ def _assert_dependency_applied(api, routes):
4949
), "Authenticated requests should be accepted"
5050
assert response.json() == "dummy response"
5151

52+
def test_openapi_content_type(self):
53+
api = self._build_api()
54+
with TestClient(api.app) as client:
55+
response = client.get(api.settings.openapi_url)
56+
assert (
57+
response.headers["content-type"]
58+
== "application/vnd.oai.openapi+json;version=3.0"
59+
)
60+
5261
def test_build_api_with_route_dependencies(self):
5362
routes = [
5463
{"path": "/collections", "method": "POST"},

stac_fastapi/pgstac/stac_fastapi/pgstac/core.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ async def get_search(
352352
sortby: Optional[str] = None,
353353
filter: Optional[str] = None,
354354
filter_lang: Optional[str] = None,
355+
intersects: Optional[str] = None,
355356
**kwargs,
356357
) -> ItemCollection:
357358
"""Cross catalog search (GET).
@@ -389,6 +390,9 @@ async def get_search(
389390
if datetime:
390391
base_args["datetime"] = datetime
391392

393+
if intersects:
394+
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
395+
392396
if sortby:
393397
# https://github.com/radiantearth/stac-spec/tree/master/api-spec/extensions/sort#http-get-or-post-form
394398
sort_param = []

stac_fastapi/pgstac/stac_fastapi/pgstac/db.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Database connection handling."""
22

33
import json
4-
from typing import Dict, Union
4+
from contextlib import contextmanager
5+
from typing import Dict, Generator, Union
56

67
import attr
78
import orjson
@@ -61,7 +62,7 @@ async def dbfunc(pool: pool, func: str, arg: Union[str, Dict]):
6162
arg -- the argument to the PostgreSQL function as either a string
6263
or a dict that will be converted into jsonb
6364
"""
64-
try:
65+
with translate_pgstac_errors():
6566
if isinstance(arg, str):
6667
async with pool.acquire() as conn:
6768
q, p = render(
@@ -80,6 +81,13 @@ async def dbfunc(pool: pool, func: str, arg: Union[str, Dict]):
8081
item=json.dumps(arg),
8182
)
8283
return await conn.fetchval(q, *p)
84+
85+
86+
@contextmanager
87+
def translate_pgstac_errors() -> Generator[None, None, None]:
88+
"""Context manager that translates pgstac errors into FastAPI errors."""
89+
try:
90+
yield
8391
except exceptions.UniqueViolationError as e:
8492
raise ConflictError from e
8593
except exceptions.NoDataFoundError as e:

stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
from typing import Optional, Union
55

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

1011
from stac_fastapi.extensions.third_party.bulk_transactions import (
1112
AsyncBaseBulkTransactionsClient,
1213
Items,
1314
)
14-
from stac_fastapi.pgstac.db import dbfunc
15+
from stac_fastapi.pgstac.db import dbfunc, translate_pgstac_errors
1516
from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks
1617
from stac_fastapi.types import stac as stac_types
1718
from stac_fastapi.types.core import AsyncBaseTransactionsClient
@@ -98,12 +99,19 @@ async def update_collection(
9899
return stac_types.Collection(**collection)
99100

100101
async def delete_item(
101-
self, item_id: str, **kwargs
102+
self, item_id: str, collection_id: str, **kwargs
102103
) -> Optional[Union[stac_types.Item, Response]]:
103104
"""Delete item."""
104105
request = kwargs["request"]
105106
pool = request.app.state.writepool
106-
await dbfunc(pool, "delete_item", item_id)
107+
async with pool.acquire() as conn:
108+
q, p = render(
109+
"SELECT * FROM delete_item(:item::text, :collection::text);",
110+
item=item_id,
111+
collection=collection_id,
112+
)
113+
with translate_pgstac_errors():
114+
await conn.fetchval(q, *p)
107115
return JSONResponse({"deleted item": item_id})
108116

109117
async def delete_collection(

stac_fastapi/pgstac/tests/api/test_api.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import orjson
55
import pytest
6+
from pystac import Collection, Extent, Item, SpatialExtent, TemporalExtent
67

78
STAC_CORE_ROUTES = [
89
"GET /",
@@ -24,6 +25,24 @@
2425
"PUT /collections/{collection_id}/items/{item_id}",
2526
]
2627

28+
GLOBAL_BBOX = [-180.0, -90.0, 180.0, 90.0]
29+
GLOBAL_GEOMETRY = {
30+
"type": "Polygon",
31+
"coordinates": (
32+
(
33+
(180.0, -90.0),
34+
(180.0, 90.0),
35+
(-180.0, 90.0),
36+
(-180.0, -90.0),
37+
(180.0, -90.0),
38+
),
39+
),
40+
}
41+
DEFAULT_EXTENT = Extent(
42+
SpatialExtent(GLOBAL_BBOX),
43+
TemporalExtent([[datetime.now(), None]]),
44+
)
45+
2746

2847
async def test_post_search_content_type(app_client):
2948
params = {"limit": 1}
@@ -183,7 +202,7 @@ async def test_app_query_extension_limit_gt10000(
183202

184203
params = {"limit": 10001}
185204
resp = await app_client.post("/search", json=params)
186-
assert resp.status_code == 400
205+
assert resp.status_code == 200
187206

188207

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

332+
new_coordinates = list()
333+
for coordinate in item["geometry"]["coordinates"][0]:
334+
new_coordinates.append([coordinate[0] * -1, coordinate[1] * -1])
335+
item["id"] = "test-item-other-hemispheres"
336+
item["geometry"]["coordinates"] = [new_coordinates]
337+
item["bbox"] = list(value * -1 for value in item["bbox"])
338+
resp = await app_client.post(f"/collections/{coll.id}/items", json=item)
339+
assert resp.status_code == 200
340+
313341
point = [150.04, -33.14]
314342
intersects = {"type": "Point", "coordinates": point}
315343

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

353+
params["intersects"] = orjson.dumps(params["intersects"]).decode("utf-8")
354+
resp = await app_client.get("/search", params=params)
355+
assert resp.status_code == 200
356+
resp_json = resp.json()
357+
assert len(resp_json["features"]) == 1
358+
325359

326360
async def test_search_line_string_intersects(
327361
load_test_data, app_client, load_test_collection
@@ -513,3 +547,33 @@ async def test_bad_collection_queryables(
513547
):
514548
resp = await app_client.get("/collections/bad-collection/queryables")
515549
assert resp.status_code == 404
550+
551+
552+
async def test_deleting_items_with_identical_ids(app_client):
553+
collection_a = Collection("collection-a", "The first collection", DEFAULT_EXTENT)
554+
collection_b = Collection("collection-b", "The second collection", DEFAULT_EXTENT)
555+
item = Item("the-item", GLOBAL_GEOMETRY, GLOBAL_BBOX, datetime.now(), {})
556+
557+
for collection in (collection_a, collection_b):
558+
response = await app_client.post(
559+
"/collections", json=collection.to_dict(include_self_link=False)
560+
)
561+
assert response.status_code == 200
562+
item_as_dict = item.to_dict(include_self_link=False)
563+
item_as_dict["collection"] = collection.id
564+
response = await app_client.post(
565+
f"/collections/{collection.id}/items", json=item_as_dict
566+
)
567+
assert response.status_code == 200
568+
response = await app_client.get(f"/collections/{collection.id}/items")
569+
assert response.status_code == 200, response.json()
570+
assert len(response.json()["features"]) == 1
571+
572+
for collection in (collection_a, collection_b):
573+
response = await app_client.delete(
574+
f"/collections/{collection.id}/items/{item.id}"
575+
)
576+
assert response.status_code == 200, response.json()
577+
response = await app_client.get(f"/collections/{collection.id}/items")
578+
assert response.status_code == 200, response.json()
579+
assert not response.json()["features"]

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ def get_search(
249249
token: Optional[str] = None,
250250
fields: Optional[List[str]] = None,
251251
sortby: Optional[str] = None,
252+
intersects: Optional[str] = None,
252253
**kwargs,
253254
) -> ItemCollection:
254255
"""GET search catalog."""
@@ -265,6 +266,9 @@ def get_search(
265266
if datetime:
266267
base_args["datetime"] = datetime
267268

269+
if intersects:
270+
base_args["intersects"] = json.loads(unquote_plus(intersects))
271+
268272
if sortby:
269273
# https://github.com/radiantearth/stac-spec/tree/master/api-spec/extensions/sort#http-get-or-post-form
270274
sort_param = []

stac_fastapi/sqlalchemy/tests/api/test_api.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def test_app_query_extension_limit_gt10000(
209209

210210
params = {"limit": 10001}
211211
resp = app_client.post("/search", json=params)
212-
assert resp.status_code == 400
212+
assert resp.status_code == 200
213213

214214

215215
def test_app_query_extension_limit_10000(
@@ -276,6 +276,16 @@ def test_search_point_intersects(load_test_data, app_client, postgres_transactio
276276
item["collection"], item, request=MockStarletteRequest
277277
)
278278

279+
new_coordinates = list()
280+
for coordinate in item["geometry"]["coordinates"][0]:
281+
new_coordinates.append([coordinate[0] * -1, coordinate[1] * -1])
282+
item["id"] = "test-item-other-hemispheres"
283+
item["geometry"]["coordinates"] = [new_coordinates]
284+
item["bbox"] = list(value * -1 for value in item["bbox"])
285+
postgres_transactions.create_item(
286+
item["collection"], item, request=MockStarletteRequest
287+
)
288+
279289
point = [150.04, -33.14]
280290
intersects = {"type": "Point", "coordinates": point}
281291

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

301+
params["intersects"] = orjson.dumps(params["intersects"]).decode("utf-8")
302+
resp = app_client.get("/search", params=params)
303+
assert resp.status_code == 200
304+
resp_json = resp.json()
305+
assert len(resp_json["features"]) == 1
306+
291307

292308
def test_datetime_non_interval(load_test_data, app_client, postgres_transactions):
293309
item = load_test_data("test_item.json")

stac_fastapi/types/stac_fastapi/types/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ def get_search(
433433
token: Optional[str] = None,
434434
fields: Optional[List[str]] = None,
435435
sortby: Optional[str] = None,
436+
intersects: Optional[str] = None,
436437
**kwargs,
437438
) -> stac_types.ItemCollection:
438439
"""Cross catalog search (GET).
@@ -627,6 +628,7 @@ async def get_search(
627628
token: Optional[str] = None,
628629
fields: Optional[List[str]] = None,
629630
sortby: Optional[str] = None,
631+
intersects: Optional[str] = None,
630632
**kwargs,
631633
) -> stac_types.ItemCollection:
632634
"""Cross catalog search (GET).

0 commit comments

Comments
 (0)