Skip to content
Open
9 changes: 1 addition & 8 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,12 @@ on:
branches: [main]
workflow_dispatch:


jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- {python: '3.12', pypgstac: '0.9.*'}
- {python: '3.12', pypgstac: '0.8.*'}
- {python: '3.11', pypgstac: '0.8.*'}
- {python: '3.10', pypgstac: '0.8.*'}
- {python: '3.9', pypgstac: '0.8.*'}
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']

timeout-minutes: 20

Expand Down Expand Up @@ -46,7 +40,6 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install .[dev,server,validation]
python -m pip install "pypgstac==${{ matrix.pypgstac }}"

- name: Run test suite
run: python -m pytest --cov stac_fastapi.pgstac --cov-report xml --cov-report term-missing
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ For more than millions of records it is recommended to either set a low connecti

### Hydration

To configure **stac-fastapi-pgstac** to [hydrate search result items in the API](https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations), set the `USE_API_HYDRATE` environment variable to `true` or explicitly set the option in the PGStac Settings object.
To configure **stac-fastapi-pgstac** to [hydrate search result items at the API level](https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations), set the `USE_API_HYDRATE` environment variable to `true`. If `false` (default) the hydration will be done in the database.

| use_api_hydrate (API) | nohydrate (PgSTAC) | Hydration |
| --- | --- | --- |
| False | False | PgSTAC |
| True | True | API |

### Migrations

Expand Down
8 changes: 6 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"buildpg",
"brotli_asgi",
"cql2>=0.3.6",
"pypgstac>=0.8,<0.10",
"pypgstac>=0.9,<0.10",
"hydraters>=0.1.3",
"typing_extensions>=4.9.0",
"jsonpatch>=1.33.0",
"json-merge-patch>=0.3.0",
Expand All @@ -25,7 +26,6 @@
extra_reqs = {
"dev": [
"pystac[validation]",
"pypgstac[psycopg]==0.9.*",
"pytest-postgresql",
"pytest",
"pytest-cov",
Expand All @@ -36,6 +36,8 @@
"httpx",
"twine",
"wheel",
"psycopg[binary]==3.1.*",
"psycopg-pool==3.1.*",
],
"docs": [
"black>=23.10.1",
Expand Down Expand Up @@ -67,6 +69,8 @@
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"License :: OSI Approved :: MIT License",
],
keywords="STAC FastAPI COG",
Expand Down
17 changes: 17 additions & 0 deletions stac_fastapi/pgstac/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,23 @@ class Settings(ApiSettings):

prefix_path: str = ""
use_api_hydrate: bool = False
"""
When USE_API_HYDRATE=TRUE, PgSTAC database will receive `NO_HYDRATE=TRUE`

| use_api_hydrate | nohydrate | Hydration |
| --- | --- | --- |
| False | False | PgSTAC |
| True | True | API |

ref: https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations
"""
exclude_hydrate_markers: bool = True
"""
In some case, PgSTAC can return `DO_NOT_MERGE_MARKER` markers (`𒍟※`).
If `EXCLUDE_HYDRATE_MARKERS=TRUE` and `USE_API_HYDRATE=TRUE`, stac-fastapi-pgstac
will exclude those values from the responses.
"""
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new @gadomski


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

Expand Down
6 changes: 5 additions & 1 deletion stac_fastapi/pgstac/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,11 @@ async def _get_base_item(collection_id: str) -> Dict[str, Any]:
# Exclude None values
base_item = {k: v for k, v in base_item.items() if v is not None}

feature = hydrate(base_item, feature)
feature = hydrate(
base_item,
feature,
strip_unmatched_markers=settings.exclude_hydrate_markers,
)

# Grab ids needed for links that may be removed by the fields extension.
collection_id = feature.get("collection")
Expand Down
17 changes: 8 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import pytest
from fastapi import APIRouter
from httpx import ASGITransport, AsyncClient
from pypgstac import __version__ as pgstac_version
from pypgstac.db import PgstacDB
from pypgstac.migrate import Migrate
from pytest_postgresql.janitor import DatabaseJanitor
Expand Down Expand Up @@ -54,12 +53,6 @@
logger = logging.getLogger(__name__)


requires_pgstac_0_9_2 = pytest.mark.skipif(
tuple(map(int, pgstac_version.split("."))) < (0, 9, 2),
reason="PgSTAC>=0.9.2 required",
)


@pytest.fixture(scope="session")
def database(postgresql_proc):
with DatabaseJanitor(
Expand All @@ -79,7 +72,13 @@ def database(postgresql_proc):
yield jan


@pytest.fixture(autouse=True)
@pytest.fixture(
params=[
"0.8.6",
"0.9.8",
],
autouse=True,
)
async def pgstac(database):
connection = f"postgresql://{database.user}:{quote(database.password)}@{database.host}:{database.port}/{database.dbname}"
yield
Expand All @@ -100,7 +99,7 @@ async def pgstac(database):
# Run all the tests that use the api_client in both db hydrate and api hydrate mode
@pytest.fixture(
params=[
# hydratation, prefix, model_validation
# API hydratation, prefix, model_validation
(False, "", False),
(False, "/router_prefix", False),
(True, "", False),
Expand Down
3 changes: 1 addition & 2 deletions tests/data/test2_item.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"nodata": 0,
"offset": 2.03976,
"data_type": "uint8",
"spatial_resolution": 60
"spatial_resolution": 80
}
]
},
Expand Down Expand Up @@ -172,7 +172,6 @@
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": ["cloud"],
"title": "Pixel Quality Assessment Band (QA_PIXEL)",
"description": "Collection 2 Level-1 Pixel Quality Assessment Band",
"raster:bands": [
{
"unit": "bit index",
Expand Down
38 changes: 30 additions & 8 deletions tests/resources/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
import pytest
from stac_pydantic import Collection

from ..conftest import requires_pgstac_0_9_2


async def test_create_collection(app_client, load_test_data: Callable):
in_json = load_test_data("test_collection.json")
Expand Down Expand Up @@ -349,11 +347,15 @@ async def test_get_collections_search(
assert len(resp.json()["collections"]) == 2


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_collection_search_freetext(
app_client, load_test_collection, load_test2_collection
):
res = await app_client.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

# free-text
resp = await app_client.get(
"/collections",
Expand Down Expand Up @@ -388,11 +390,15 @@ async def test_collection_search_freetext(
assert len(resp.json()["collections"]) == 0


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_collection_search_freetext_advanced(
app_client_advanced_freetext, load_test_collection, load_test2_collection
):
res = await app_client_advanced_freetext.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

# free-text
resp = await app_client_advanced_freetext.get(
"/collections",
Expand Down Expand Up @@ -436,9 +442,13 @@ async def test_collection_search_freetext_advanced(
assert len(resp.json()["collections"]) == 0


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_all_collections_with_pagination(app_client, load_test_data):
res = await app_client.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

data = load_test_data("test_collection.json")
collection_id = data["id"]
for ii in range(0, 12):
Expand Down Expand Up @@ -468,9 +478,13 @@ async def test_all_collections_with_pagination(app_client, load_test_data):
assert {"root", "self"} == {link["rel"] for link in links}


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_all_collections_without_pagination(app_client_no_ext, load_test_data):
res = await app_client_no_ext.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

data = load_test_data("test_collection.json")
collection_id = data["id"]
for ii in range(0, 12):
Expand All @@ -491,11 +505,15 @@ async def test_all_collections_without_pagination(app_client_no_ext, load_test_d
assert {"root", "self"} == {link["rel"] for link in links}


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_get_collections_search_pagination(
app_client, load_test_collection, load_test2_collection
):
res = await app_client.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

resp = await app_client.get("/collections")
assert resp.json()["numberReturned"] == 2
assert resp.json()["numberMatched"] == 2
Expand Down Expand Up @@ -621,12 +639,16 @@ async def test_get_collections_search_pagination(
assert {"root", "self"} == {link["rel"] for link in links}


@requires_pgstac_0_9_2
@pytest.mark.xfail(strict=False)
@pytest.mark.asyncio
async def test_get_collections_search_offset_1(
app_client, load_test_collection, load_test2_collection
):
res = await app_client.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

# BUG: pgstac doesn't return a `prev` link when limit is not set
# offset=1, should have a `previous` link
resp = await app_client.get(
Expand Down
78 changes: 75 additions & 3 deletions tests/resources/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@

from stac_fastapi.pgstac.models.links import CollectionLinks

from ..conftest import requires_pgstac_0_9_2


async def test_create_collection(app_client, load_test_data: Callable):
in_json = load_test_data("test_collection.json")
Expand Down Expand Up @@ -1693,9 +1691,13 @@ async def test_get_search_link_media(app_client):
assert get_self_link["type"] == "application/geo+json"


@requires_pgstac_0_9_2
@pytest.mark.asyncio
async def test_item_search_freetext(app_client, load_test_data, load_test_collection):
res = await app_client.get("/_mgmt/health")
pgstac_version = res.json()["pgstac"]["pgstac_version"]
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
pass

test_item = load_test_data("test_item.json")
resp = await app_client.post(
f"/collections/{test_item['collection']}/items", json=test_item
Expand All @@ -1722,3 +1724,73 @@ async def test_item_search_freetext(app_client, load_test_data, load_test_collec
params={"q": "yo"},
)
assert resp.json()["numberReturned"] == 0


@pytest.mark.asyncio
async def test_item_asset_change(app_client, load_test_data):
"""Check that changing item_assets in collection does
not affect existing items if hydration should not occur.

"""
# load collection
data = load_test_data("test2_collection.json")
collection_id = data["id"]

resp = await app_client.post("/collections", json=data)
assert "item_assets" in data
assert resp.status_code == 201
assert "item_assets" in resp.json()

# load items
test_item = load_test_data("test2_item.json")
resp = await app_client.post(f"/collections/{collection_id}/items", json=test_item)
assert resp.status_code == 201

# check list of items
resp = await app_client.get(
f"/collections/{collection_id}/items", params={"limit": 1}
)
assert len(resp.json()["features"]) == 1
assert resp.status_code == 200

# NOTE: API or PgSTAC Hydration we should get the same values as original Item
assert (
test_item["assets"]["red"]["raster:bands"]
== resp.json()["features"][0]["assets"]["red"]["raster:bands"]
)

# NOTE: `description` is not in the item body but in the collection's item-assets
# because it's not in the original item it won't be hydrated
assert not resp.json()["features"][0]["assets"]["qa_pixel"].get("description")

###########################################################################
# Remove item_assets in collection
operations = [{"op": "remove", "path": "/item_assets"}]
resp = await app_client.patch(f"/collections/{collection_id}", json=operations)
assert resp.status_code == 200

# Make sure item_assets is not in collection response
resp = await app_client.get(f"/collections/{collection_id}")
assert resp.status_code == 200
assert "item_assets" not in resp.json()
###########################################################################

resp = await app_client.get(
f"/collections/{collection_id}/items", params={"limit": 1}
)
assert len(resp.json()["features"]) == 1
assert resp.status_code == 200

# NOTE: here we should only get `scale`, `offset` and `spatial_resolution`
# because the other values were stripped on ingestion (dehydration is a default in PgSTAC)
# scale and offset are no in item-asset and spatial_resolution is different, so the value in the item body is kept
assert ["scale", "offset", "spatial_resolution"] == list(
resp.json()["features"][0]["assets"]["red"]["raster:bands"][0]
)

# Only run this test for PgSTAC hydratation because `exclude_hydrate_markers=True` by default
if not app_client._transport.app.state.settings.use_api_hydrate:
# NOTE: `description` is not in the original item but in the collection's item-assets
# We get "𒍟※" because PgSTAC set it when ingesting (`description`is item-assets)
# because we removed item-assets, pgstac cannot hydrate this field, and thus return "𒍟※"
assert resp.json()["features"][0]["assets"]["qa_pixel"]["description"] == "𒍟※"
Loading