diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e68864e..99a6551f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +## [v4.1.0] - 2025-05-04 + +### Added + +- Added logging to bulk insertion methods to provide detailed feedback on errors encountered during operations. [#364](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/364) +- Introduced the `RAISE_ON_BULK_ERROR` environment variable to control whether bulk insertion methods raise exceptions on errors (`true`) or log warnings and continue processing (`false`). [#364](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/364) +- Added code coverage reporting to the test suite using pytest-cov. [#87](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/87) + +### Changed + +- Updated dynamic mapping for items to map long values to double versus float. [#326](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/326) +- Extended Datetime Search to search on start_datetime and end_datetime as well as datetime fields. [#182](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/182) +- Changed item update operation to use Elasticsearch index API instead of delete and create for better efficiency and atomicity. [#75](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/75) +- Bulk insertion via `BulkTransactionsClient` now strictly validates all STAC Items using the Pydantic model before insertion. Any invalid item will immediately raise a `ValidationError`, ensuring consistent validation with single-item inserts and preventing invalid STAC Items from being stored. This validation is enforced regardless of the `RAISE_ON_BULK_ERROR` setting. [#368](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/368) + +### Fixed + +- Refactored `create_item` and `update_item` methods to share unified logic, ensuring consistent conflict detection, validation, and database operations. [#368](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/368) + ## [v4.0.0] - 2025-04-23 ### Added @@ -348,7 +367,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Use genexp in execute_search and get_all_collections to return results. - Added db_to_stac serializer to item_collection method in core.py. -[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v4.0.0...main +[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v4.1.0...main +[v4.1.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v4.0.0...v4.1.0 [v4.0.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v3.2.5...v4.0.0 [v3.2.5]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v3.2.4...v3.2.5 [v3.2.4]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v3.2.3...v3.2.4 diff --git a/Makefile b/Makefile index a16fe6d9..3440b7a2 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,10 @@ test-opensearch: .PHONY: test test: - -$(run_es) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh elasticsearch:9200 && cd stac_fastapi/tests/ && pytest' + -$(run_es) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh elasticsearch:9200 && cd stac_fastapi/tests/ && pytest --cov=stac_fastapi --cov-report=term-missing' docker compose down - -$(run_os) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh opensearch:9202 && cd stac_fastapi/tests/ && pytest' + -$(run_os) /bin/bash -c 'export && ./scripts/wait-for-it-es.sh opensearch:9202 && cd stac_fastapi/tests/ && pytest --cov=stac_fastapi --cov-report=term-missing' docker compose down .PHONY: run-database-es diff --git a/README.md b/README.md index 896db23f..1ae2f085 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ You can customize additional settings in your `.env` file: | `BACKEND` | Tests-related variable | `elasticsearch` or `opensearch` based on the backend | Optional | | `ELASTICSEARCH_VERSION` | Version of Elasticsearch to use. | `8.11.0` | Optional | | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional | -| `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional | +| `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional +| `RAISE_ON_BULK_ERROR` | Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional | > [!NOTE] > The variables `ES_HOST`, `ES_PORT`, `ES_USE_SSL`, and `ES_VERIFY_CERTS` apply to both Elasticsearch and OpenSearch backends, so there is no need to rename the key names to `OS_` even if you're using OpenSearch. diff --git a/compose.yml b/compose.yml index 24905483..946df97b 100644 --- a/compose.yml +++ b/compose.yml @@ -9,7 +9,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8080 - RELOAD=true @@ -41,7 +41,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-opensearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8082 - RELOAD=true diff --git a/examples/auth/compose.basic_auth.yml b/examples/auth/compose.basic_auth.yml index 37de4013..907b53cb 100644 --- a/examples/auth/compose.basic_auth.yml +++ b/examples/auth/compose.basic_auth.yml @@ -9,7 +9,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8080 - RELOAD=true @@ -42,7 +42,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-opensearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8082 - RELOAD=true diff --git a/examples/auth/compose.oauth2.yml b/examples/auth/compose.oauth2.yml index 09a3aa7b..e2d78a42 100644 --- a/examples/auth/compose.oauth2.yml +++ b/examples/auth/compose.oauth2.yml @@ -9,7 +9,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8080 - RELOAD=true @@ -43,7 +43,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-opensearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8082 - RELOAD=true diff --git a/examples/auth/compose.route_dependencies.yml b/examples/auth/compose.route_dependencies.yml index da73e2bb..5278b8b3 100644 --- a/examples/auth/compose.route_dependencies.yml +++ b/examples/auth/compose.route_dependencies.yml @@ -9,7 +9,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8080 - RELOAD=true @@ -42,7 +42,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-opensearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8082 - RELOAD=true diff --git a/examples/rate_limit/compose.rate_limit.yml b/examples/rate_limit/compose.rate_limit.yml index 0f516dae..a3015b7c 100644 --- a/examples/rate_limit/compose.rate_limit.yml +++ b/examples/rate_limit/compose.rate_limit.yml @@ -9,7 +9,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-elasticsearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Elasticsearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8080 - RELOAD=true @@ -42,7 +42,7 @@ services: environment: - STAC_FASTAPI_TITLE=stac-fastapi-opensearch - STAC_FASTAPI_DESCRIPTION=A STAC FastAPI with an Opensearch backend - - STAC_FASTAPI_VERSION=4.0.0 + - STAC_FASTAPI_VERSION=4.1.0 - APP_HOST=0.0.0.0 - APP_PORT=8082 - RELOAD=true diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 3ac14efc..f994b619 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -676,46 +676,65 @@ class TransactionsClient(AsyncBaseTransactionsClient): @overrides async def create_item( self, collection_id: str, item: Union[Item, ItemCollection], **kwargs - ) -> Optional[stac_types.Item]: - """Create an item in the collection. + ) -> Union[stac_types.Item, str]: + """ + Create an item or a feature collection of items in the specified collection. Args: - collection_id (str): The id of the collection to add the item to. - item (stac_types.Item): The item to be added to the collection. - kwargs: Additional keyword arguments. + collection_id (str): The ID of the collection to add the item(s) to. + item (Union[Item, ItemCollection]): A single item or a collection of items to be added. + **kwargs: Additional keyword arguments, such as `request` and `refresh`. Returns: - stac_types.Item: The created item. + Union[stac_types.Item, str]: The created item if a single item is added, or a summary string + indicating the number of items successfully added and errors if a collection of items is added. Raises: - NotFound: If the specified collection is not found in the database. - ConflictError: If the item in the specified collection already exists. - + NotFoundError: If the specified collection is not found in the database. + ConflictError: If an item with the same ID already exists in the collection. """ - item = item.model_dump(mode="json") - base_url = str(kwargs["request"].base_url) + request = kwargs.get("request") + base_url = str(request.base_url) - # If a feature collection is posted - if item["type"] == "FeatureCollection": + # Convert Pydantic model to dict for uniform processing + item_dict = item.model_dump(mode="json") + + # Handle FeatureCollection (bulk insert) + if item_dict["type"] == "FeatureCollection": bulk_client = BulkTransactionsClient( database=self.database, settings=self.settings ) + features = item_dict["features"] processed_items = [ bulk_client.preprocess_item( - item, base_url, BulkTransactionMethod.INSERT + feature, base_url, BulkTransactionMethod.INSERT ) - for item in item["features"] + for feature in features ] - - await self.database.bulk_async( - collection_id, processed_items, refresh=kwargs.get("refresh", False) + attempted = len(processed_items) + success, errors = await self.database.bulk_async( + collection_id, + processed_items, + refresh=kwargs.get("refresh", False), ) + if errors: + logger.error( + f"Bulk async operation encountered errors for collection {collection_id}: {errors} (attempted {attempted})" + ) + else: + logger.info( + f"Bulk async operation succeeded with {success} actions for collection {collection_id}." + ) + return f"Successfully added {success} Items. {attempted - success} errors occurred." - return None - else: - item = await self.database.prep_create_item(item=item, base_url=base_url) - await self.database.create_item(item, refresh=kwargs.get("refresh", False)) - return ItemSerializer.db_to_stac(item, base_url) + # Handle single item + await self.database.create_item( + item_dict, + refresh=kwargs.get("refresh", False), + base_url=base_url, + exist_ok=False, + ) + return ItemSerializer.db_to_stac(item_dict, base_url) @overrides async def update_item( @@ -738,12 +757,12 @@ async def update_item( """ item = item.model_dump(mode="json") base_url = str(kwargs["request"].base_url) - now = datetime_type.now(timezone.utc).isoformat().replace("+00:00", "Z") + now = datetime_type.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") item["properties"]["updated"] = now - await self.database.check_collection_exists(collection_id) - await self.delete_item(item_id=item_id, collection_id=collection_id) - await self.create_item(collection_id=collection_id, item=Item(**item), **kwargs) + await self.database.create_item( + item, refresh=kwargs.get("refresh", False), base_url=base_url, exist_ok=True + ) return ItemSerializer.db_to_stac(item, base_url) @@ -876,7 +895,7 @@ def preprocess_item( The preprocessed item. """ exist_ok = method == BulkTransactionMethod.UPSERT - return self.database.sync_prep_create_item( + return self.database.bulk_sync_prep_create_item( item=item, base_url=base_url, exist_ok=exist_ok ) @@ -900,19 +919,32 @@ def bulk_item_insert( else: base_url = "" - processed_items = [ - self.preprocess_item(item, base_url, items.method) - for item in items.items.values() - ] + processed_items = [] + for item in items.items.values(): + try: + validated = Item(**item) if not isinstance(item, Item) else item + processed_items.append( + self.preprocess_item( + validated.model_dump(mode="json"), base_url, items.method + ) + ) + except ValidationError: + # Immediately raise on the first invalid item (strict mode) + raise - # not a great way to get the collection_id-- should be part of the method signature collection_id = processed_items[0]["collection"] - - self.database.bulk_sync( - collection_id, processed_items, refresh=kwargs.get("refresh", False) + attempted = len(processed_items) + success, errors = self.database.bulk_sync( + collection_id, + processed_items, + refresh=kwargs.get("refresh", False), ) + if errors: + logger.error(f"Bulk sync operation encountered errors: {errors}") + else: + logger.info(f"Bulk sync operation succeeded with {success} actions.") - return f"Successfully added {len(processed_items)} Items." + return f"Successfully added/updated {success} Items. {attempted - success} errors occurred." _DEFAULT_QUERYABLES: Dict[str, Dict[str, Any]] = { diff --git a/stac_fastapi/core/stac_fastapi/core/database_logic.py b/stac_fastapi/core/stac_fastapi/core/database_logic.py index 7ddd8af7..85ebcf21 100644 --- a/stac_fastapi/core/stac_fastapi/core/database_logic.py +++ b/stac_fastapi/core/stac_fastapi/core/database_logic.py @@ -96,7 +96,13 @@ class Geometry(Protocol): # noqa }, # Default all other strings not otherwise specified to keyword {"strings": {"match_mapping_type": "string", "mapping": {"type": "keyword"}}}, - {"numerics": {"match_mapping_type": "long", "mapping": {"type": "float"}}}, + {"long_to_double": {"match_mapping_type": "long", "mapping": {"type": "double"}}}, + { + "double_to_double": { + "match_mapping_type": "double", + "mapping": {"type": "double"}, + } + }, ] ES_ITEMS_MAPPINGS = { diff --git a/stac_fastapi/core/stac_fastapi/core/version.py b/stac_fastapi/core/stac_fastapi/core/version.py index 6356730f..e42ce685 100644 --- a/stac_fastapi/core/stac_fastapi/core/version.py +++ b/stac_fastapi/core/stac_fastapi/core/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/stac_fastapi/elasticsearch/setup.py b/stac_fastapi/elasticsearch/setup.py index aa4a9371..fe12fb07 100644 --- a/stac_fastapi/elasticsearch/setup.py +++ b/stac_fastapi/elasticsearch/setup.py @@ -6,7 +6,7 @@ desc = f.read() install_requires = [ - "stac-fastapi-core==4.0.0", + "stac-fastapi-core==4.1.0", "elasticsearch[async]~=8.18.0", "uvicorn~=0.23.0", "starlette>=0.35.0,<0.36.0", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 9ccf009a..0eff0062 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -90,7 +90,7 @@ api = StacApi( title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"), description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"), - api_version=os.getenv("STAC_FASTAPI_VERSION", "4.0.0"), + api_version=os.getenv("STAC_FASTAPI_VERSION", "4.1.0"), settings=settings, extensions=extensions, client=CoreClient( diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/config.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/config.py index 2044a4b2..37e1ba5b 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/config.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/config.py @@ -86,6 +86,7 @@ class ElasticsearchSettings(ApiSettings, ApiBaseSettings): indexed_fields: Set[str] = {"datetime"} enable_response_models: bool = False enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) + raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) @property def create_client(self): @@ -106,6 +107,7 @@ class AsyncElasticsearchSettings(ApiSettings, ApiBaseSettings): indexed_fields: Set[str] = {"datetime"} enable_response_models: bool = False enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) + raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) @property def create_client(self): diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index f57ef9bb..9a773230 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -128,8 +128,20 @@ async def delete_item_index(collection_id: str): class DatabaseLogic(BaseDatabaseLogic): """Database logic.""" - client = AsyncElasticsearchSettings().create_client - sync_client = SyncElasticsearchSettings().create_client + async_settings: AsyncElasticsearchSettings = attr.ib( + factory=AsyncElasticsearchSettings + ) + sync_settings: SyncElasticsearchSettings = attr.ib( + factory=SyncElasticsearchSettings + ) + + client = attr.ib(init=False) + sync_client = attr.ib(init=False) + + def __attrs_post_init__(self): + """Initialize clients after the class is instantiated.""" + self.client = self.async_settings.create_client + self.sync_client = self.sync_settings.create_client item_serializer: Type[ItemSerializer] = attr.ib(default=ItemSerializer) collection_serializer: Type[CollectionSerializer] = attr.ib( @@ -294,8 +306,8 @@ def apply_collections_filter(search: Search, collection_ids: List[str]): return search.filter("terms", collection=collection_ids) @staticmethod - def apply_datetime_filter(search: Search, datetime_search): - """Apply a filter to search based on datetime field. + def apply_datetime_filter(search: Search, datetime_search: dict): + """Apply a filter to search on datetime, start_datetime, and end_datetime fields. Args: search (Search): The search object to filter. @@ -304,17 +316,109 @@ def apply_datetime_filter(search: Search, datetime_search): Returns: Search: The filtered search object. """ + should = [] + + # If the request is a single datetime return + # items with datetimes equal to the requested datetime OR + # the requested datetime is between their start and end datetimes if "eq" in datetime_search: - search = search.filter( - "term", **{"properties__datetime": datetime_search["eq"]} + should.extend( + [ + Q( + "bool", + filter=[ + Q( + "term", + properties__datetime=datetime_search["eq"], + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "lte": datetime_search["eq"], + }, + ), + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["eq"], + }, + ), + ], + ), + ] ) + + # If the request is a date range return + # items with datetimes within the requested date range OR + # their startdatetime ithin the requested date range OR + # their enddatetime ithin the requested date range OR + # the requested daterange within their start and end datetimes else: - search = search.filter( - "range", properties__datetime={"lte": datetime_search["lte"]} - ) - search = search.filter( - "range", properties__datetime={"gte": datetime_search["gte"]} + should.extend( + [ + Q( + "bool", + filter=[ + Q( + "range", + properties__datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "lte": datetime_search["gte"] + }, + ), + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["lte"] + }, + ), + ], + ), + ] ) + + search = search.query(Q("bool", filter=[Q("bool", should=should)])) + return search @staticmethod @@ -607,7 +711,7 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item( + async def async_prep_create_item( self, item: Item, base_url: str, exist_ok: bool = False ) -> Item: """ @@ -637,44 +741,114 @@ async def prep_create_item( return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item( + async def bulk_async_prep_create_item( self, item: Item, base_url: str, exist_ok: bool = False ) -> Item: """ Prepare an item for insertion into the database. - This method performs pre-insertion preparation on the given `item`, - such as checking if the collection the item belongs to exists, - and optionally verifying that an item with the same ID does not already exist in the database. + This method performs pre-insertion preparation on the given `item`, such as: + - Verifying that the collection the item belongs to exists. + - Optionally checking if an item with the same ID already exists in the database. + - Serializing the item into a database-compatible format. Args: - item (Item): The item to be inserted into the database. - base_url (str): The base URL used for constructing URLs for the item. - exist_ok (bool): Indicates whether the item can exist already. + item (Item): The item to be prepared for insertion. + base_url (str): The base URL used to construct the item's self URL. + exist_ok (bool): Indicates whether the item can already exist in the database. + If False, a `ConflictError` is raised if the item exists. Returns: - Item: The item after preparation is done. + Item: The prepared item, serialized into a database-compatible format. Raises: NotFoundError: If the collection that the item belongs to does not exist in the database. - ConflictError: If an item with the same ID already exists in the collection. + ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False, + and `RAISE_ON_BULK_ERROR` is set to `true`. """ - item_id = item["id"] - collection_id = item["collection"] - if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=collection_id): - raise NotFoundError(f"Collection {collection_id} does not exist") + logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.") - if not exist_ok and self.sync_client.exists( - index=index_alias_by_collection_id(collection_id), - id=mk_item_id(item_id, collection_id), + # Check if the collection exists + await self.check_collection_exists(collection_id=item["collection"]) + + # Check if the item already exists in the database + if not exist_ok and await self.client.exists( + index=index_alias_by_collection_id(item["collection"]), + id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" + error_message = ( + f"Item {item['id']} in collection {item['collection']} already exists." ) + if self.async_settings.raise_on_bulk_error: + raise ConflictError(error_message) + else: + logger.warning( + f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false." + ) + + # Serialize the item into a database-compatible format + prepped_item = self.item_serializer.stac_to_db(item, base_url) + logger.debug(f"Item {item['id']} prepared successfully.") + return prepped_item + + def bulk_sync_prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: + """ + Prepare an item for insertion into the database. - return self.item_serializer.stac_to_db(item, base_url) + This method performs pre-insertion preparation on the given `item`, such as: + - Verifying that the collection the item belongs to exists. + - Optionally checking if an item with the same ID already exists in the database. + - Serializing the item into a database-compatible format. + + Args: + item (Item): The item to be prepared for insertion. + base_url (str): The base URL used to construct the item's self URL. + exist_ok (bool): Indicates whether the item can already exist in the database. + If False, a `ConflictError` is raised if the item exists. - async def create_item(self, item: Item, refresh: bool = False): + Returns: + Item: The prepared item, serialized into a database-compatible format. + + Raises: + NotFoundError: If the collection that the item belongs to does not exist in the database. + ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False, + and `RAISE_ON_BULK_ERROR` is set to `true`. + """ + logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.") + + # Check if the collection exists + if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=item["collection"]): + raise NotFoundError(f"Collection {item['collection']} does not exist") + + # Check if the item already exists in the database + if not exist_ok and self.sync_client.exists( + index=index_alias_by_collection_id(item["collection"]), + id=mk_item_id(item["id"], item["collection"]), + ): + error_message = ( + f"Item {item['id']} in collection {item['collection']} already exists." + ) + if self.sync_settings.raise_on_bulk_error: + raise ConflictError(error_message) + else: + logger.warning( + f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false." + ) + + # Serialize the item into a database-compatible format + prepped_item = self.item_serializer.stac_to_db(item, base_url) + logger.debug(f"Item {item['id']} prepared successfully.") + return prepped_item + + async def create_item( + self, + item: Item, + refresh: bool = False, + base_url: str = "", + exist_ok: bool = False, + ): """Database logic for creating one item. Args: @@ -690,18 +864,16 @@ async def create_item(self, item: Item, refresh: bool = False): # todo: check if collection exists, but cache item_id = item["id"] collection_id = item["collection"] - es_resp = await self.client.index( + item = await self.async_prep_create_item( + item=item, base_url=base_url, exist_ok=exist_ok + ) + await self.client.index( index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), document=item, refresh=refresh, ) - if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) - async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -867,52 +1039,72 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): await delete_item_index(collection_id) async def bulk_async( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: - """Perform a bulk insert of items into the database asynchronously. + self, + collection_id: str, + processed_items: List[Item], + refresh: bool = False, + ) -> Tuple[int, List[Dict[str, Any]]]: + """ + Perform a bulk insert of items into the database asynchronously. Args: - self: The instance of the object calling this function. collection_id (str): The ID of the collection to which the items belong. processed_items (List[Item]): A list of `Item` objects to be inserted into the database. refresh (bool): Whether to refresh the index after the bulk insert (default: False). + Returns: + Tuple[int, List[Dict[str, Any]]]: A tuple containing: + - The number of successfully processed actions (`success`). + - A list of errors encountered during the bulk operation (`errors`). + Notes: - This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The - insert is performed asynchronously, and the event loop is used to run the operation in a separate executor. The - `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True, the - index is refreshed after the bulk insert. The function does not return any value. + This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. + The insert is performed asynchronously, and the event loop is used to run the operation in a separate executor. + The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True, + the index is refreshed after the bulk insert. """ - await helpers.async_bulk( + raise_on_error = self.async_settings.raise_on_bulk_error + success, errors = await helpers.async_bulk( self.client, mk_actions(collection_id, processed_items), refresh=refresh, - raise_on_error=False, + raise_on_error=raise_on_error, ) + return success, errors def bulk_sync( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: - """Perform a bulk insert of items into the database synchronously. + self, + collection_id: str, + processed_items: List[Item], + refresh: bool = False, + ) -> Tuple[int, List[Dict[str, Any]]]: + """ + Perform a bulk insert of items into the database synchronously. Args: - self: The instance of the object calling this function. collection_id (str): The ID of the collection to which the items belong. processed_items (List[Item]): A list of `Item` objects to be inserted into the database. refresh (bool): Whether to refresh the index after the bulk insert (default: False). + Returns: + Tuple[int, List[Dict[str, Any]]]: A tuple containing: + - The number of successfully processed actions (`success`). + - A list of errors encountered during the bulk operation (`errors`). + Notes: - This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The - insert is performed synchronously and blocking, meaning that the function does not return until the insert has + This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. + The insert is performed synchronously and blocking, meaning that the function does not return until the insert has completed. The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to - True, the index is refreshed after the bulk insert. The function does not return any value. + True, the index is refreshed after the bulk insert. """ - helpers.bulk( + raise_on_error = self.sync_settings.raise_on_bulk_error + success, errors = helpers.bulk( self.sync_client, mk_actions(collection_id, processed_items), refresh=refresh, - raise_on_error=False, + raise_on_error=raise_on_error, ) + return success, errors # DANGER async def delete_items(self) -> None: diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py index 6356730f..e42ce685 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/stac_fastapi/opensearch/setup.py b/stac_fastapi/opensearch/setup.py index c7427500..ab9e4018 100644 --- a/stac_fastapi/opensearch/setup.py +++ b/stac_fastapi/opensearch/setup.py @@ -6,7 +6,7 @@ desc = f.read() install_requires = [ - "stac-fastapi-core==4.0.0", + "stac-fastapi-core==4.1.0", "opensearch-py~=2.8.0", "opensearch-py[async]~=2.8.0", "uvicorn~=0.23.0", diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index e7df7779..021579e8 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -90,7 +90,7 @@ api = StacApi( title=os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"), description=os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"), - api_version=os.getenv("STAC_FASTAPI_VERSION", "4.0.0"), + api_version=os.getenv("STAC_FASTAPI_VERSION", "4.1.0"), settings=settings, extensions=extensions, client=CoreClient( diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/config.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/config.py index 00498468..4c305fda 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/config.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/config.py @@ -83,6 +83,7 @@ class OpensearchSettings(ApiSettings, ApiBaseSettings): indexed_fields: Set[str] = {"datetime"} enable_response_models: bool = False enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) + raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) @property def create_client(self): @@ -103,6 +104,7 @@ class AsyncOpensearchSettings(ApiSettings, ApiBaseSettings): indexed_fields: Set[str] = {"datetime"} enable_response_models: bool = False enable_direct_response: bool = get_bool_env("ENABLE_DIRECT_RESPONSE", default=False) + raise_on_bulk_error: bool = get_bool_env("RAISE_ON_BULK_ERROR", default=False) @property def create_client(self): diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 3184fa06..66c8d3e6 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -13,7 +13,6 @@ from opensearchpy.helpers.search import Search from starlette.requests import Request -from stac_fastapi.core import serializers from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.database_logic import ( COLLECTIONS_INDEX, @@ -31,6 +30,7 @@ mk_item_id, ) from stac_fastapi.core.extensions import filter +from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon from stac_fastapi.opensearch.config import ( AsyncOpensearchSettings as AsyncSearchSettings, @@ -143,14 +143,20 @@ async def delete_item_index(collection_id: str) -> None: class DatabaseLogic(BaseDatabaseLogic): """Database logic.""" - client = AsyncSearchSettings().create_client - sync_client = SyncSearchSettings().create_client + async_settings: AsyncSearchSettings = attr.ib(factory=AsyncSearchSettings) + sync_settings: SyncSearchSettings = attr.ib(factory=SyncSearchSettings) - item_serializer: Type[serializers.ItemSerializer] = attr.ib( - default=serializers.ItemSerializer - ) - collection_serializer: Type[serializers.CollectionSerializer] = attr.ib( - default=serializers.CollectionSerializer + client = attr.ib(init=False) + sync_client = attr.ib(init=False) + + def __attrs_post_init__(self): + """Initialize clients after the class is instantiated.""" + self.client = self.async_settings.create_client + self.sync_client = self.sync_settings.create_client + + item_serializer: Type[ItemSerializer] = attr.ib(default=ItemSerializer) + collection_serializer: Type[CollectionSerializer] = attr.ib( + default=CollectionSerializer ) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -329,7 +335,7 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] @staticmethod def apply_datetime_filter(search: Search, datetime_search): - """Apply a filter to search based on datetime field. + """Apply a filter to search based on datetime field, start_datetime, and end_datetime fields. Args: search (Search): The search object to filter. @@ -338,17 +344,109 @@ def apply_datetime_filter(search: Search, datetime_search): Returns: Search: The filtered search object. """ + should = [] + + # If the request is a single datetime return + # items with datetimes equal to the requested datetime OR + # the requested datetime is between their start and end datetimes if "eq" in datetime_search: - search = search.filter( - "term", **{"properties__datetime": datetime_search["eq"]} + should.extend( + [ + Q( + "bool", + filter=[ + Q( + "term", + properties__datetime=datetime_search["eq"], + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "lte": datetime_search["eq"], + }, + ), + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["eq"], + }, + ), + ], + ), + ] ) + + # If the request is a date range return + # items with datetimes within the requested date range OR + # their startdatetime ithin the requested date range OR + # their enddatetime ithin the requested date range OR + # the requested daterange within their start and end datetimes else: - search = search.filter( - "range", properties__datetime={"lte": datetime_search["lte"]} - ) - search = search.filter( - "range", properties__datetime={"gte": datetime_search["gte"]} + should.extend( + [ + Q( + "bool", + filter=[ + Q( + "range", + properties__datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["gte"], + "lte": datetime_search["lte"], + }, + ), + ], + ), + Q( + "bool", + filter=[ + Q( + "range", + properties__start_datetime={ + "lte": datetime_search["gte"] + }, + ), + Q( + "range", + properties__end_datetime={ + "gte": datetime_search["lte"] + }, + ), + ], + ), + ] ) + + search = search.query(Q("bool", filter=[Q("bool", should=should)])) + return search @staticmethod @@ -633,7 +731,7 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item( + async def async_prep_create_item( self, item: Item, base_url: str, exist_ok: bool = False ) -> Item: """ @@ -663,44 +761,113 @@ async def prep_create_item( return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item( + async def bulk_async_prep_create_item( self, item: Item, base_url: str, exist_ok: bool = False ) -> Item: """ Prepare an item for insertion into the database. - This method performs pre-insertion preparation on the given `item`, - such as checking if the collection the item belongs to exists, - and optionally verifying that an item with the same ID does not already exist in the database. + This method performs pre-insertion preparation on the given `item`, such as: + - Verifying that the collection the item belongs to exists. + - Optionally checking if an item with the same ID already exists in the database. + - Serializing the item into a database-compatible format. Args: - item (Item): The item to be inserted into the database. - base_url (str): The base URL used for constructing URLs for the item. - exist_ok (bool): Indicates whether the item can exist already. + item (Item): The item to be prepared for insertion. + base_url (str): The base URL used to construct the item's self URL. + exist_ok (bool): Indicates whether the item can already exist in the database. + If False, a `ConflictError` is raised if the item exists. Returns: - Item: The item after preparation is done. + Item: The prepared item, serialized into a database-compatible format. Raises: NotFoundError: If the collection that the item belongs to does not exist in the database. - ConflictError: If an item with the same ID already exists in the collection. + ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False, + and `RAISE_ON_BULK_ERROR` is set to `true`. """ - item_id = item["id"] - collection_id = item["collection"] - if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=collection_id): - raise NotFoundError(f"Collection {collection_id} does not exist") + logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.") - if not exist_ok and self.sync_client.exists( - index=index_alias_by_collection_id(collection_id), - id=mk_item_id(item_id, collection_id), + # Check if the collection exists + await self.check_collection_exists(collection_id=item["collection"]) + + # Check if the item already exists in the database + if not exist_ok and await self.client.exists( + index=index_alias_by_collection_id(item["collection"]), + id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" + error_message = ( + f"Item {item['id']} in collection {item['collection']} already exists." ) + if self.async_settings.raise_on_bulk_error: + raise ConflictError(error_message) + else: + logger.warning( + f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false." + ) + # Serialize the item into a database-compatible format + prepped_item = self.item_serializer.stac_to_db(item, base_url) + logger.debug(f"Item {item['id']} prepared successfully.") + return prepped_item + + def bulk_sync_prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: + """ + Prepare an item for insertion into the database. - return self.item_serializer.stac_to_db(item, base_url) + This method performs pre-insertion preparation on the given `item`, such as: + - Verifying that the collection the item belongs to exists. + - Optionally checking if an item with the same ID already exists in the database. + - Serializing the item into a database-compatible format. + + Args: + item (Item): The item to be prepared for insertion. + base_url (str): The base URL used to construct the item's self URL. + exist_ok (bool): Indicates whether the item can already exist in the database. + If False, a `ConflictError` is raised if the item exists. - async def create_item(self, item: Item, refresh: bool = False): + Returns: + Item: The prepared item, serialized into a database-compatible format. + + Raises: + NotFoundError: If the collection that the item belongs to does not exist in the database. + ConflictError: If an item with the same ID already exists in the collection and `exist_ok` is False, + and `RAISE_ON_BULK_ERROR` is set to `true`. + """ + logger.debug(f"Preparing item {item['id']} in collection {item['collection']}.") + + # Check if the collection exists + if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=item["collection"]): + raise NotFoundError(f"Collection {item['collection']} does not exist") + + # Check if the item already exists in the database + if not exist_ok and self.sync_client.exists( + index=index_alias_by_collection_id(item["collection"]), + id=mk_item_id(item["id"], item["collection"]), + ): + error_message = ( + f"Item {item['id']} in collection {item['collection']} already exists." + ) + if self.sync_settings.raise_on_bulk_error: + raise ConflictError(error_message) + else: + logger.warning( + f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false." + ) + + # Serialize the item into a database-compatible format + prepped_item = self.item_serializer.stac_to_db(item, base_url) + logger.debug(f"Item {item['id']} prepared successfully.") + return prepped_item + + async def create_item( + self, + item: Item, + refresh: bool = False, + base_url: str = "", + exist_ok: bool = False, + ): """Database logic for creating one item. Args: @@ -716,18 +883,16 @@ async def create_item(self, item: Item, refresh: bool = False): # todo: check if collection exists, but cache item_id = item["id"] collection_id = item["collection"] - es_resp = await self.client.index( + item = await self.async_prep_create_item( + item=item, base_url=base_url, exist_ok=exist_ok + ) + await self.client.index( index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), body=item, refresh=refresh, ) - if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) - async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -893,52 +1058,72 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): await delete_item_index(collection_id) async def bulk_async( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: - """Perform a bulk insert of items into the database asynchronously. + self, + collection_id: str, + processed_items: List[Item], + refresh: bool = False, + ) -> Tuple[int, List[Dict[str, Any]]]: + """ + Perform a bulk insert of items into the database asynchronously. Args: - self: The instance of the object calling this function. collection_id (str): The ID of the collection to which the items belong. processed_items (List[Item]): A list of `Item` objects to be inserted into the database. refresh (bool): Whether to refresh the index after the bulk insert (default: False). + Returns: + Tuple[int, List[Dict[str, Any]]]: A tuple containing: + - The number of successfully processed actions (`success`). + - A list of errors encountered during the bulk operation (`errors`). + Notes: - This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The - insert is performed asynchronously, and the event loop is used to run the operation in a separate executor. The - `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True, the - index is refreshed after the bulk insert. The function does not return any value. + This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. + The insert is performed asynchronously, and the event loop is used to run the operation in a separate executor. + The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to True, + the index is refreshed after the bulk insert. """ - await helpers.async_bulk( + raise_on_error = self.async_settings.raise_on_bulk_error + success, errors = await helpers.async_bulk( self.client, mk_actions(collection_id, processed_items), refresh=refresh, - raise_on_error=False, + raise_on_error=raise_on_error, ) + return success, errors def bulk_sync( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: - """Perform a bulk insert of items into the database synchronously. + self, + collection_id: str, + processed_items: List[Item], + refresh: bool = False, + ) -> Tuple[int, List[Dict[str, Any]]]: + """ + Perform a bulk insert of items into the database synchronously. Args: - self: The instance of the object calling this function. collection_id (str): The ID of the collection to which the items belong. processed_items (List[Item]): A list of `Item` objects to be inserted into the database. refresh (bool): Whether to refresh the index after the bulk insert (default: False). + Returns: + Tuple[int, List[Dict[str, Any]]]: A tuple containing: + - The number of successfully processed actions (`success`). + - A list of errors encountered during the bulk operation (`errors`). + Notes: - This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. The - insert is performed synchronously and blocking, meaning that the function does not return until the insert has + This function performs a bulk insert of `processed_items` into the database using the specified `collection_id`. + The insert is performed synchronously and blocking, meaning that the function does not return until the insert has completed. The `mk_actions` function is called to generate a list of actions for the bulk insert. If `refresh` is set to - True, the index is refreshed after the bulk insert. The function does not return any value. + True, the index is refreshed after the bulk insert. """ - helpers.bulk( + raise_on_error = self.sync_settings.raise_on_bulk_error + success, errors = helpers.bulk( self.sync_client, mk_actions(collection_id, processed_items), refresh=refresh, - raise_on_error=False, + raise_on_error=raise_on_error, ) + return success, errors # DANGER async def delete_items(self) -> None: diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py index 6356730f..e42ce685 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index fb128f74..807da5e4 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -1,8 +1,12 @@ +import random import uuid -from datetime import datetime, timedelta, timezone +from copy import deepcopy +from datetime import datetime, timedelta import pytest +from stac_fastapi.types.errors import ConflictError + from ..conftest import create_collection, create_item ROUTES = { @@ -206,7 +210,13 @@ async def test_app_fields_extension_return_all_properties( feature = resp_json["features"][0] assert len(feature["properties"]) >= len(item["properties"]) for expected_prop, expected_value in item["properties"].items(): - if expected_prop in ("datetime", "created", "updated"): + if expected_prop in ( + "datetime", + "start_datetime", + "end_datetime", + "created", + "updated", + ): assert feature["properties"][expected_prop][0:19] == expected_value[0:19] else: assert feature["properties"][expected_prop] == expected_value @@ -260,9 +270,9 @@ async def test_app_sort_extension_get_asc(app_client, txn_client, ctx): second_item["id"] = "another-item" another_item_date = datetime.strptime( first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.isoformat().replace( - "+00:00", "Z" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" ) await create_item(txn_client, second_item) @@ -282,10 +292,11 @@ async def test_app_sort_extension_get_desc(app_client, txn_client, ctx): second_item["id"] = "another-item" another_item_date = datetime.strptime( first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.isoformat().replace( - "+00:00", "Z" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" ) + await create_item(txn_client, second_item) resp = await app_client.get("/search?sortby=-properties.datetime") @@ -303,10 +314,11 @@ async def test_app_sort_extension_post_asc(app_client, txn_client, ctx): second_item["id"] = "another-item" another_item_date = datetime.strptime( first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.isoformat().replace( - "+00:00", "Z" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" ) + await create_item(txn_client, second_item) params = { @@ -328,9 +340,9 @@ async def test_app_sort_extension_post_desc(app_client, txn_client, ctx): second_item["id"] = "another-item" another_item_date = datetime.strptime( first_item["properties"]["datetime"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - timedelta(days=1) - second_item["properties"]["datetime"] = another_item_date.isoformat().replace( - "+00:00", "Z" + ) - timedelta(days=1) + second_item["properties"]["datetime"] = another_item_date.strftime( + "%Y-%m-%dT%H:%M:%SZ" ) await create_item(txn_client, second_item) @@ -411,7 +423,22 @@ async def test_search_point_does_not_intersect(app_client, ctx): @pytest.mark.asyncio -async def test_datetime_non_interval(app_client, ctx): +async def test_datetime_response_format(app_client, txn_client, ctx): + first_item = dict(ctx.item) + + second_item = deepcopy(first_item) + second_item["id"] = "second-item" + second_item["properties"]["datetime"] = None + + await create_item(txn_client, second_item) + + third_item = deepcopy(first_item) + third_item["id"] = "third-item" + del third_item["properties"]["start_datetime"] + del third_item["properties"]["end_datetime"] + + await create_item(txn_client, third_item) + dt_formats = [ "2020-02-12T12:30:22+00:00", "2020-02-12T12:30:22.00Z", @@ -432,6 +459,150 @@ async def test_datetime_non_interval(app_client, ctx): assert resp_json["features"][0]["properties"]["datetime"][0:19] == dt[0:19] +@pytest.mark.asyncio +async def test_datetime_non_interval(app_client, txn_client, ctx): + first_item = dict(ctx.item) + + second_item = deepcopy(first_item) + second_item["id"] = "second-item" + second_item["properties"]["datetime"] = None + + await create_item(txn_client, second_item) + + third_item = deepcopy(first_item) + third_item["id"] = "third-item" + del third_item["properties"]["start_datetime"] + del third_item["properties"]["end_datetime"] + + await create_item(txn_client, third_item) + + dt_formats = [ + "2020-02-12T12:30:22+00:00", + "2020-02-12T12:30:22.00Z", + "2020-02-12T12:30:22Z", + "2020-02-12T12:30:22.00+00:00", + ] + + for dt in dt_formats: + params = { + "datetime": dt, + "collections": [ctx.item["collection"]], + } + + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 3 + + +@pytest.mark.asyncio +async def test_datetime_interval(app_client, txn_client, ctx): + first_item = dict(ctx.item) + + second_item = deepcopy(first_item) + second_item["id"] = "second-item" + second_item["properties"]["datetime"] = None + + await create_item(txn_client, second_item) + + third_item = deepcopy(first_item) + third_item["id"] = "third-item" + del third_item["properties"]["start_datetime"] + del third_item["properties"]["end_datetime"] + + await create_item(txn_client, third_item) + + dt_formats = [ + "2020-02-06T12:30:22+00:00/2020-02-13T12:30:22+00:00", + "2020-02-12T12:30:22.00Z/2020-02-20T12:30:22.00Z", + "2020-02-12T12:30:22Z/2020-02-13T12:30:22Z", + "2020-02-06T12:30:22.00+00:00/2020-02-20T12:30:22.00+00:00", + ] + + for dt in dt_formats: + params = { + "datetime": dt, + "collections": [ctx.item["collection"]], + } + + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 3 + + +@pytest.mark.asyncio +async def test_datetime_bad_non_interval(app_client, txn_client, ctx): + first_item = dict(ctx.item) + + second_item = deepcopy(first_item) + second_item["id"] = "second-item" + second_item["properties"]["datetime"] = None + + await create_item(txn_client, second_item) + + third_item = deepcopy(first_item) + third_item["id"] = "third-item" + del third_item["properties"]["start_datetime"] + del third_item["properties"]["end_datetime"] + + await create_item(txn_client, third_item) + + dt_formats = [ + "2020-02-06T12:30:22+00:00", + "2020-02-06T12:30:22.00Z", + "2020-02-06T12:30:22Z", + "2020-02-06T12:30:22.00+00:00", + ] + + for dt in dt_formats: + params = { + "datetime": dt, + "collections": [ctx.item["collection"]], + } + + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 0 + + +@pytest.mark.asyncio +async def test_datetime_bad_interval(app_client, txn_client, ctx): + first_item = dict(ctx.item) + + second_item = deepcopy(first_item) + second_item["id"] = "second-item" + second_item["properties"]["datetime"] = None + + await create_item(txn_client, second_item) + + third_item = deepcopy(first_item) + third_item["id"] = "third-item" + del third_item["properties"]["start_datetime"] + del third_item["properties"]["end_datetime"] + + await create_item(txn_client, third_item) + + dt_formats = [ + "1920-02-04T12:30:22+00:00/1920-02-06T12:30:22+00:00", + "1920-02-04T12:30:22.00Z/1920-02-06T12:30:22.00Z", + "1920-02-04T12:30:22Z/1920-02-06T12:30:22Z", + "1920-02-04T12:30:22.00+00:00/1920-02-06T12:30:22.00+00:00", + ] + + for dt in dt_formats: + params = { + "datetime": dt, + "collections": [ctx.item["collection"]], + } + + resp = await app_client.post("/search", json=params) + assert resp.status_code == 200 + resp_json = resp.json() + assert len(resp_json["features"]) == 0 + + @pytest.mark.asyncio async def test_bbox_3d(app_client, ctx): australia_bbox = [106.343365, -47.199523, 0.1, 168.218365, -19.437288, 0.1] @@ -460,3 +631,70 @@ async def test_search_line_string_intersects(app_client, ctx): resp_json = resp.json() assert len(resp_json["features"]) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "value, expected", + [ + (32767, 1), # Short Limit + (2147483647, 1), # Int Limit + (2147483647 + 5000, 1), # Above Int Limit + (21474836470, 1), # Above Int Limit + ], +) +async def test_big_int_eo_search( + app_client, txn_client, test_item, test_collection, value, expected +): + random_str = "".join(random.choice("abcdef") for _ in range(5)) + collection_id = f"test-collection-eo-{random_str}" + + test_collection["id"] = collection_id + test_collection["stac_extensions"] = [ + "https://stac-extensions.github.io/eo/v2.0.0/schema.json" + ] + + test_item["collection"] = collection_id + test_item["stac_extensions"] = test_collection["stac_extensions"] + + # Remove "eo:bands" to simplify the test + del test_item["properties"]["eo:bands"] + + # Attribute to test + attr = "eo:full_width_half_max" + + try: + await create_collection(txn_client, test_collection) + except ConflictError: + pass + + # Create items with deterministic offsets + for val in [value, value + 100, value - 100]: + item = deepcopy(test_item) + item["id"] = str(uuid.uuid4()) + item["properties"][attr] = val + await create_item(txn_client, item) + + # Search for the exact value + params = { + "collections": [collection_id], + "filter": { + "args": [ + { + "args": [ + {"property": f"properties.{attr}"}, + value, + ], + "op": "=", + } + ], + "op": "and", + }, + } + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + + # Validate results + results = {x["properties"][attr] for x in resp_json["features"]} + assert len(results) == expected + assert results == {value} diff --git a/stac_fastapi/tests/clients/test_bulk_transactions.py b/stac_fastapi/tests/clients/test_bulk_transactions.py new file mode 100644 index 00000000..a7405938 --- /dev/null +++ b/stac_fastapi/tests/clients/test_bulk_transactions.py @@ -0,0 +1,150 @@ +import os +import uuid +from copy import deepcopy + +import pytest +from pydantic import ValidationError + +from stac_fastapi.extensions.third_party.bulk_transactions import Items +from stac_fastapi.types.errors import ConflictError + +from ..conftest import MockRequest, create_item + +if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": + from stac_fastapi.opensearch.config import OpensearchSettings as SearchSettings +else: + from stac_fastapi.elasticsearch.config import ( + ElasticsearchSettings as SearchSettings, + ) + + +@pytest.mark.asyncio +async def test_bulk_item_insert(ctx, core_client, txn_client, bulk_txn_client): + items = {} + for _ in range(10): + _item = deepcopy(ctx.item) + _item["id"] = str(uuid.uuid4()) + items[_item["id"]] = _item + + # fc = es_core.item_collection(coll["id"], request=MockStarletteRequest) + # assert len(fc["features"]) == 0 + + bulk_txn_client.bulk_item_insert(Items(items=items), refresh=True) + + fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) + assert len(fc["features"]) >= 10 + + +@pytest.mark.asyncio +async def test_bulk_item_insert_with_raise_on_error( + ctx, core_client, txn_client, bulk_txn_client +): + """ + Test bulk_item_insert behavior with RAISE_ON_BULK_ERROR set to true and false. + + This test verifies that when RAISE_ON_BULK_ERROR is set to true, a ConflictError + is raised for conflicting items. When set to false, the operation logs errors + and continues gracefully. + """ + + # Insert an initial item to set up a conflict + initial_item = deepcopy(ctx.item) + initial_item["id"] = str(uuid.uuid4()) + await create_item(txn_client, initial_item) + + # Verify the initial item is inserted + fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) + assert len(fc["features"]) >= 1 + + # Create conflicting items (same ID as the initial item) + conflicting_items = {initial_item["id"]: deepcopy(initial_item)} + + # Test with RAISE_ON_BULK_ERROR set to true + os.environ["RAISE_ON_BULK_ERROR"] = "true" + bulk_txn_client.database.sync_settings = SearchSettings() + + with pytest.raises(ConflictError): + bulk_txn_client.bulk_item_insert(Items(items=conflicting_items), refresh=True) + + # Test with RAISE_ON_BULK_ERROR set to false + os.environ["RAISE_ON_BULK_ERROR"] = "false" + bulk_txn_client.database.sync_settings = SearchSettings() # Reinitialize settings + result = bulk_txn_client.bulk_item_insert( + Items(items=conflicting_items), refresh=True + ) + + # Validate the results + assert "Successfully added/updated 1 Items" in result + + # Clean up the inserted item + await txn_client.delete_item(initial_item["id"], ctx.item["collection"]) + + +@pytest.mark.asyncio +async def test_feature_collection_insert( + core_client, + txn_client, + ctx, +): + features = [] + for _ in range(10): + _item = deepcopy(ctx.item) + _item["id"] = str(uuid.uuid4()) + features.append(_item) + + feature_collection = {"type": "FeatureCollection", "features": features} + + await create_item(txn_client, feature_collection) + + fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) + assert len(fc["features"]) >= 10 + + +@pytest.mark.asyncio +async def test_bulk_item_insert_validation_error(ctx, core_client, bulk_txn_client): + items = {} + # Add 9 valid items + for _ in range(9): + _item = deepcopy(ctx.item) + _item["id"] = str(uuid.uuid4()) + items[_item["id"]] = _item + + # Add 1 invalid item (e.g., missing "datetime") + invalid_item = deepcopy(ctx.item) + invalid_item["id"] = str(uuid.uuid4()) + invalid_item["properties"].pop( + "datetime", None + ) # Remove datetime to make it invalid + items[invalid_item["id"]] = invalid_item + + # The bulk insert should raise a ValidationError due to the invalid item + with pytest.raises(ValidationError): + bulk_txn_client.bulk_item_insert(Items(items=items), refresh=True) + + +@pytest.mark.asyncio +async def test_feature_collection_insert_validation_error( + core_client, + txn_client, + ctx, +): + features = [] + # Add 9 valid items + for _ in range(9): + _item = deepcopy(ctx.item) + _item["id"] = str(uuid.uuid4()) + features.append(_item) + + # Add 1 invalid item (e.g., missing "datetime") + invalid_item = deepcopy(ctx.item) + invalid_item["id"] = str(uuid.uuid4()) + invalid_item["properties"].pop( + "datetime", None + ) # Remove datetime to make it invalid + features.append(invalid_item) + + feature_collection = {"type": "FeatureCollection", "features": features} + + # Assert that a ValidationError is raised due to the invalid item + with pytest.raises(ValidationError): + await create_item(txn_client, feature_collection) diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_es_os.py similarity index 86% rename from stac_fastapi/tests/clients/test_elasticsearch.py rename to stac_fastapi/tests/clients/test_es_os.py index a0867ad3..0f200826 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_es_os.py @@ -5,10 +5,9 @@ import pytest from stac_pydantic import Item, api -from stac_fastapi.extensions.third_party.bulk_transactions import Items from stac_fastapi.types.errors import ConflictError, NotFoundError -from ..conftest import MockRequest, create_item +from ..conftest import MockRequest @pytest.mark.asyncio @@ -275,48 +274,6 @@ async def test_delete_item(ctx, core_client, txn_client): ) -@pytest.mark.asyncio -async def test_bulk_item_insert(ctx, core_client, txn_client, bulk_txn_client): - items = {} - for _ in range(10): - _item = deepcopy(ctx.item) - _item["id"] = str(uuid.uuid4()) - items[_item["id"]] = _item - - # fc = es_core.item_collection(coll["id"], request=MockStarletteRequest) - # assert len(fc["features"]) == 0 - - bulk_txn_client.bulk_item_insert(Items(items=items), refresh=True) - - fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) - assert len(fc["features"]) >= 10 - - # for item in items: - # es_transactions.delete_item( - # item["id"], item["collection"], request=MockStarletteRequest - # ) - - -@pytest.mark.asyncio -async def test_feature_collection_insert( - core_client, - txn_client, - ctx, -): - features = [] - for _ in range(10): - _item = deepcopy(ctx.item) - _item["id"] = str(uuid.uuid4()) - features.append(_item) - - feature_collection = {"type": "FeatureCollection", "features": features} - - await create_item(txn_client, feature_collection) - - fc = await core_client.item_collection(ctx.collection["id"], request=MockRequest()) - assert len(fc["features"]) >= 10 - - @pytest.mark.asyncio async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" diff --git a/stac_fastapi/tests/data/test_item.json b/stac_fastapi/tests/data/test_item.json index f3d78da8..bf860a20 100644 --- a/stac_fastapi/tests/data/test_item.json +++ b/stac_fastapi/tests/data/test_item.json @@ -1,510 +1,512 @@ { - "type": "Feature", - "id": "test-item", - "stac_version": "1.0.0", - "stac_extensions": [ - "https://stac-extensions.github.io/eo/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.0.0/schema.json" - ], - "geometry": { - "coordinates": [ - [ - [ - 152.15052873427666, - -33.82243006904891 - ], - [ - 150.1000346138806, - -34.257132625788756 - ], - [ - 149.5776607193635, - -32.514709769700254 - ], - [ - 151.6262528041627, - -32.08081674221862 - ], - [ - 152.15052873427666, - -33.82243006904891 - ] - ] - ], - "type": "Polygon" - }, - "properties": { - "datetime": "2020-02-12T12:30:22Z", - "landsat:scene_id": "LC82081612020043LGN00", - "landsat:row": "161", - "gsd": 15, - "eo:bands": [ - { - "gsd": 30, - "name": "B1", - "common_name": "coastal", - "center_wavelength": 0.44, - "full_width_half_max": 0.02 - }, - { - "gsd": 30, - "name": "B2", - "common_name": "blue", - "center_wavelength": 0.48, - "full_width_half_max": 0.06 - }, - { - "gsd": 30, - "name": "B3", - "common_name": "green", - "center_wavelength": 0.56, - "full_width_half_max": 0.06 - }, - { - "gsd": 30, - "name": "B4", - "common_name": "red", - "center_wavelength": 0.65, - "full_width_half_max": 0.04 - }, - { - "gsd": 30, - "name": "B5", - "common_name": "nir", - "center_wavelength": 0.86, - "full_width_half_max": 0.03 - }, - { - "gsd": 30, - "name": "B6", - "common_name": "swir16", - "center_wavelength": 1.6, - "full_width_half_max": 0.08 - }, - { - "gsd": 30, - "name": "B7", - "common_name": "swir22", - "center_wavelength": 2.2, - "full_width_half_max": 0.2 - }, - { - "gsd": 15, - "name": "B8", - "common_name": "pan", - "center_wavelength": 0.59, - "full_width_half_max": 0.18 - }, - { - "gsd": 30, - "name": "B9", - "common_name": "cirrus", - "center_wavelength": 1.37, - "full_width_half_max": 0.02 - }, - { - "gsd": 100, - "name": "B10", - "common_name": "lwir11", - "center_wavelength": 10.9, - "full_width_half_max": 0.8 - }, - { - "gsd": 100, - "name": "B11", - "common_name": "lwir12", - "center_wavelength": 12, - "full_width_half_max": 1 - } - ], - "landsat:revision": "00", - "view:sun_azimuth": -148.83296771, - "instrument": "OLI_TIRS", - "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT", - "eo:cloud_cover": 0, - "landsat:tier": "RT", - "landsat:processing_level": "L1GT", - "landsat:column": "208", - "platform": "landsat-8", - "proj:epsg": 32756, - "view:sun_elevation": -37.30791534, - "view:off_nadir": 0, - "height": 2500, - "width": 2500, - "proj:centroid": { - "lat": -33.168923093262876, - "lon": 150.86362466374058 - }, - "grid:code": "MGRS-56HLJ" - }, - "bbox": [ - 149.57574, - -34.25796, - 152.15194, - -32.07915 - ], - "collection": "test-collection", - "assets": { - "ANG": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt", - "type": "text/plain", - "title": "Angle Coefficients File", - "description": "Collection 2 Level-1 Angle Coefficients File (ANG)" - }, - "SR_B1": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Coastal/Aerosol Band (B1)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B1", - "common_name": "coastal", - "center_wavelength": 0.44, - "full_width_half_max": 0.02 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B2": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Blue Band (B2)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B2", - "common_name": "blue", - "center_wavelength": 0.48, - "full_width_half_max": 0.06 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B3": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Green Band (B3)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B3", - "common_name": "green", - "center_wavelength": 0.56, - "full_width_half_max": 0.06 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B4": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Red Band (B4)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B4", - "common_name": "red", - "center_wavelength": 0.65, - "full_width_half_max": 0.04 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B5": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Near Infrared Band 0.8 (B5)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B5", - "common_name": "nir08", - "center_wavelength": 0.86, - "full_width_half_max": 0.03 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B6": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Short-wave Infrared Band 1.6 (B6)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B6", - "common_name": "swir16", - "center_wavelength": 1.6, - "full_width_half_max": 0.08 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B7": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Short-wave Infrared Band 2.2 (B7)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B7", - "common_name": "swir22", - "center_wavelength": 2.2, - "full_width_half_max": 0.2 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_QA": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Surface Temperature Quality Assessment Band", - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_B10": { - "gsd": 100, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Surface Temperature Band (B10)", - "eo:bands": [ - { - "gsd": 100, - "name": "ST_B10", - "common_name": "lwir11", - "center_wavelength": 10.9, - "full_width_half_max": 0.8 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "MTL.txt": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt", - "type": "text/plain", - "title": "Product Metadata File", - "description": "Collection 2 Level-1 Product Metadata File (MTL)" - }, - "MTL.xml": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml", - "type": "application/xml", - "title": "Product Metadata File (xml)", - "description": "Collection 2 Level-1 Product Metadata File (xml)" - }, - "ST_DRAD": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Downwelled Radiance Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_DRAD", - "description": "downwelled radiance" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_EMIS": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Emissivity Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_EMIS", - "description": "emissivity" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_EMSD": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Emissivity Standard Deviation Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_EMSD", - "description": "emissivity standard deviation" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - } - }, - "links": [ - { - "href": "http://localhost:8081/collections/landsat-8-l1/items/LC82081612020043", - "rel": "self", - "type": "application/geo+json" - }, - { - "href": "http://localhost:8081/collections/landsat-8-l1", - "rel": "parent", - "type": "application/json" - }, - { - "href": "http://localhost:8081/collections/landsat-8-l1", - "rel": "collection", - "type": "application/json" - }, - { - "href": "http://localhost:8081/", - "rel": "root", - "type": "application/json" - } - ] + "type": "Feature", + "id": "test-item", + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json" + ], + "geometry": { + "coordinates": [ + [ + [ + 152.15052873427666, + -33.82243006904891 + ], + [ + 150.1000346138806, + -34.257132625788756 + ], + [ + 149.5776607193635, + -32.514709769700254 + ], + [ + 151.6262528041627, + -32.08081674221862 + ], + [ + 152.15052873427666, + -33.82243006904891 + ] + ] + ], + "type": "Polygon" + }, + "properties": { + "datetime": "2020-02-12T12:30:22Z", + "start_datetime": "2020-02-08T12:30:22Z", + "end_datetime": "2020-02-16T12:30:22Z", + "landsat:scene_id": "LC82081612020043LGN00", + "landsat:row": "161", + "gsd": 15, + "eo:bands": [ + { + "gsd": 30, + "name": "B1", + "common_name": "coastal", + "center_wavelength": 0.44, + "full_width_half_max": 0.02 + }, + { + "gsd": 30, + "name": "B2", + "common_name": "blue", + "center_wavelength": 0.48, + "full_width_half_max": 0.06 + }, + { + "gsd": 30, + "name": "B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + }, + { + "gsd": 30, + "name": "B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + }, + { + "gsd": 30, + "name": "B5", + "common_name": "nir", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + }, + { + "gsd": 30, + "name": "B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + }, + { + "gsd": 30, + "name": "B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + }, + { + "gsd": 15, + "name": "B8", + "common_name": "pan", + "center_wavelength": 0.59, + "full_width_half_max": 0.18 + }, + { + "gsd": 30, + "name": "B9", + "common_name": "cirrus", + "center_wavelength": 1.37, + "full_width_half_max": 0.02 + }, + { + "gsd": 100, + "name": "B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + }, + { + "gsd": 100, + "name": "B11", + "common_name": "lwir12", + "center_wavelength": 12, + "full_width_half_max": 1 + } + ], + "landsat:revision": "00", + "view:sun_azimuth": -148.83296771, + "instrument": "OLI_TIRS", + "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT", + "eo:cloud_cover": 0, + "landsat:tier": "RT", + "landsat:processing_level": "L1GT", + "landsat:column": "208", + "platform": "landsat-8", + "proj:epsg": 32756, + "view:sun_elevation": -37.30791534, + "view:off_nadir": 0, + "height": 2500, + "width": 2500, + "proj:centroid": { + "lat": -33.168923093262876, + "lon": 150.86362466374058 + }, + "grid:code": "MGRS-56HLJ" + }, + "bbox": [ + 149.57574, + -34.25796, + 152.15194, + -32.07915 + ], + "collection": "test-collection", + "assets": { + "ANG": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt", + "type": "text/plain", + "title": "Angle Coefficients File", + "description": "Collection 2 Level-1 Angle Coefficients File (ANG)" + }, + "SR_B1": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Coastal/Aerosol Band (B1)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B1", + "common_name": "coastal", + "center_wavelength": 0.44, + "full_width_half_max": 0.02 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B2": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Blue Band (B2)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B2", + "common_name": "blue", + "center_wavelength": 0.48, + "full_width_half_max": 0.06 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B3": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Green Band (B3)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B4": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Red Band (B4)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B5": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Near Infrared Band 0.8 (B5)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B5", + "common_name": "nir08", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B6": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Short-wave Infrared Band 1.6 (B6)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B7": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Short-wave Infrared Band 2.2 (B7)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_QA": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Surface Temperature Quality Assessment Band", + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_B10": { + "gsd": 100, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Surface Temperature Band (B10)", + "eo:bands": [ + { + "gsd": 100, + "name": "ST_B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "MTL.txt": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt", + "type": "text/plain", + "title": "Product Metadata File", + "description": "Collection 2 Level-1 Product Metadata File (MTL)" + }, + "MTL.xml": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml", + "type": "application/xml", + "title": "Product Metadata File (xml)", + "description": "Collection 2 Level-1 Product Metadata File (xml)" + }, + "ST_DRAD": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Downwelled Radiance Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_DRAD", + "description": "downwelled radiance" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_EMIS": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Emissivity Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_EMIS", + "description": "emissivity" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_EMSD": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Emissivity Standard Deviation Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_EMSD", + "description": "emissivity standard deviation" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + } + }, + "links": [ + { + "href": "http://localhost:8081/collections/landsat-8-l1/items/LC82081612020043", + "rel": "self", + "type": "application/geo+json" + }, + { + "href": "http://localhost:8081/collections/landsat-8-l1", + "rel": "parent", + "type": "application/json" + }, + { + "href": "http://localhost:8081/collections/landsat-8-l1", + "rel": "collection", + "type": "application/json" + }, + { + "href": "http://localhost:8081/", + "rel": "root", + "type": "application/json" + } + ] } \ No newline at end of file diff --git a/stac_fastapi/tests/resources/test_item.py b/stac_fastapi/tests/resources/test_item.py index 5313b1fa..6f344b19 100644 --- a/stac_fastapi/tests/resources/test_item.py +++ b/stac_fastapi/tests/resources/test_item.py @@ -392,6 +392,25 @@ async def test_item_search_temporal_window_post(app_client, ctx, load_test_data) assert resp_json["features"][0]["id"] == test_item["id"] +@pytest.mark.asyncio +async def test_item_search_temporal_intersecting_window_post(app_client, ctx): + """Test POST search with two-tailed spatio-temporal query (core)""" + test_item = ctx.item + + item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) + item_date_before = item_date - timedelta(days=10) + item_date_after = item_date - timedelta(days=2) + + params = { + "collections": [test_item["collection"]], + "intersects": test_item["geometry"], + "datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}", + } + resp = await app_client.post("/search", json=params) + resp_json = resp.json() + assert resp_json["features"][0]["id"] == test_item["id"] + + @pytest.mark.asyncio async def test_item_search_temporal_open_window(app_client, ctx): """Test POST search with open spatio-temporal query (core)"""