diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a966248b..864b52e3 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -65,7 +65,7 @@ jobs: strategy: matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] backend: [ "elasticsearch7", "elasticsearch8", "opensearch"] name: Python ${{ matrix.python-version }} testing with ${{ matrix.backend }} diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 833c1021..3606d654 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -20,10 +20,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | diff --git a/dockerfiles/Dockerfile.deploy.es b/dockerfiles/Dockerfile.deploy.es index 2eab7b9d..ef87d1c4 100644 --- a/dockerfiles/Dockerfile.deploy.es +++ b/dockerfiles/Dockerfile.deploy.es @@ -3,9 +3,12 @@ FROM python:3.10-slim RUN apt-get update && \ apt-get -y upgrade && \ apt-get -y install gcc && \ + apt-get -y install build-essential git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* + + ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt WORKDIR /app diff --git a/dockerfiles/Dockerfile.dev.es b/dockerfiles/Dockerfile.dev.es index 009f9681..f0516031 100644 --- a/dockerfiles/Dockerfile.dev.es +++ b/dockerfiles/Dockerfile.dev.es @@ -4,7 +4,7 @@ FROM python:3.10-slim # update apt pkgs, and install build-essential for ciso8601 RUN apt-get update && \ apt-get -y upgrade && \ - apt-get install -y build-essential git && \ + apt-get -y install build-essential git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/dockerfiles/Dockerfile.dev.os b/dockerfiles/Dockerfile.dev.os index d9dc8b0a..d6488a74 100644 --- a/dockerfiles/Dockerfile.dev.os +++ b/dockerfiles/Dockerfile.dev.os @@ -4,10 +4,11 @@ FROM python:3.10-slim # update apt pkgs, and install build-essential for ciso8601 RUN apt-get update && \ apt-get -y upgrade && \ - apt-get install -y build-essential && \ + apt-get -y install build-essential && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +RUN apt-get -y install git # update certs used by Requests ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt diff --git a/dockerfiles/Dockerfile.docs b/dockerfiles/Dockerfile.docs index f1fe63b8..937ae014 100644 --- a/dockerfiles/Dockerfile.docs +++ b/dockerfiles/Dockerfile.docs @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim # build-essential is required to build a wheel for ciso8601 RUN apt update && apt install -y build-essential diff --git a/stac_fastapi/core/setup.py b/stac_fastapi/core/setup.py index 01191c1b..4d6ec2b3 100644 --- a/stac_fastapi/core/setup.py +++ b/stac_fastapi/core/setup.py @@ -10,9 +10,9 @@ "attrs>=23.2.0", "pydantic", "stac_pydantic>=3", - "stac-fastapi.types==3.0.0", - "stac-fastapi.api==3.0.0", - "stac-fastapi.extensions==3.0.0", + "stac-fastapi.types@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/types", + "stac-fastapi.api@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/api", + "stac-fastapi.extensions@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/extensions", "orjson", "overrides", "geojson-pydantic", @@ -26,12 +26,11 @@ description="Core library for the Elasticsearch and Opensearch stac-fastapi backends.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py index 0043cfb8..50d2062c 100644 --- a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py +++ b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py @@ -1,7 +1,7 @@ """Base database logic.""" import abc -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Iterable, List, Optional class BaseDatabaseLogic(abc.ABC): @@ -29,6 +29,30 @@ async def create_item(self, item: Dict, refresh: bool = False) -> None: """Create an item in the database.""" pass + @abc.abstractmethod + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: Dict, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a item in the database follows RF7396.""" + pass + + @abc.abstractmethod + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a item in the database follows RF6902.""" + pass + @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False @@ -41,6 +65,28 @@ async def create_collection(self, collection: Dict, refresh: bool = False) -> No """Create a collection in the database.""" pass + @abc.abstractmethod + async def merge_patch_collection( + self, + collection_id: str, + collection: Dict, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a collection in the database follows RF7396.""" + pass + + @abc.abstractmethod + async def json_patch_collection( + self, + collection_id: str, + operations: List, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a collection in the database follows RF6902.""" + pass + @abc.abstractmethod async def find_collection(self, collection_id: str) -> Dict: """Find a collection in the database.""" diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 56afcbc8..ba4282ac 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -377,7 +377,7 @@ async def get_item( @staticmethod def _return_date( - interval: Optional[Union[DateTimeType, str]] + interval: Optional[Union[DateTimeType, str]], ) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -459,7 +459,7 @@ async def get_search( sortby: Optional[str] = None, q: Optional[List[str]] = None, intersects: Optional[str] = None, - filter: Optional[str] = None, + filter_expr: Optional[str] = None, filter_lang: Optional[str] = None, **kwargs, ) -> stac_types.ItemCollection: @@ -493,11 +493,9 @@ async def get_search( "token": token, "query": orjson.loads(query) if query else query, "q": q, + "datetime": datetime, } - if datetime: - base_args["datetime"] = self._format_datetime_range(datetime) - if intersects: base_args["intersects"] = orjson.loads(unquote_plus(intersects)) @@ -507,12 +505,12 @@ async def get_search( for sort in sortby ] - if filter: + if filter_expr: base_args["filter-lang"] = "cql2-json" base_args["filter"] = orjson.loads( - unquote_plus(filter) + unquote_plus(filter_expr) if filter_lang == "cql2-json" - else to_cql2(parse_cql2_text(filter)) + else to_cql2(parse_cql2_text(filter_expr)) ) if fields: @@ -594,10 +592,11 @@ async def post_search( ) # only cql2_json is supported here - if hasattr(search_request, "filter"): - cql2_filter = getattr(search_request, "filter", None) + if search_request.filter_expr: try: - search = self.database.apply_cql2_filter(search, cql2_filter) + search = self.database.apply_cql2_filter( + search, search_request.filter_expr + ) except Exception as e: raise HTTPException( status_code=400, detail=f"Error with cql2_json filter: {e}" @@ -734,6 +733,62 @@ async def update_item( return ItemSerializer.db_to_stac(item, base_url) + @overrides + async def merge_patch_item( + self, collection_id: str, item_id: str, item: stac_types.PartialItem, **kwargs + ) -> Optional[stac_types.Item]: + """Patch an item in the collection following RF7396.. + + Args: + collection_id (str): The ID of the collection the item belongs to. + item_id (str): The ID of the item to be updated. + item (stac_types.PartialItem): The partial item data. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Item: The patched item object. + + """ + base_url = str(kwargs["request"].base_url) + + item = await self.database.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item=item, + base_url=base_url, + ) + return ItemSerializer.db_to_stac(item, base_url=base_url) + + @overrides + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[stac_types.PatchOperation], + **kwargs, + ) -> Optional[stac_types.Item]: + """Patch an item in the collection following RF6902. + + Args: + collection_id (str): The ID of the collection the item belongs to. + item_id (str): The ID of the item to be updated. + operations (List): List of operations to run on item. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Item: The patched item object. + + """ + base_url = str(kwargs["request"].base_url) + + item = await self.database.json_patch_item( + collection_id=collection_id, + item_id=item_id, + base_url=base_url, + operations=operations, + ) + return ItemSerializer.db_to_stac(item, base_url=base_url) + @overrides async def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -814,6 +869,60 @@ async def update_collection( extensions=[type(ext).__name__ for ext in self.database.extensions], ) + @overrides + async def merge_patch_collection( + self, collection_id: str, collection: stac_types.PartialCollection, **kwargs + ) -> Optional[stac_types.Collection]: + """Patch a collection following RF7396.. + + Args: + collection_id (str): The ID of the collection to patch. + collection (stac_types.Collection): The partial collection data. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Collection: The patched collection object. + + """ + collection = await self.database.merge_patch_collection( + collection_id=collection_id, + base_url=str(kwargs["request"].base_url), + collection=collection, + ) + + return CollectionSerializer.db_to_stac( + collection, + kwargs["request"], + extensions=[type(ext).__name__ for ext in self.database.extensions], + ) + + @overrides + async def json_patch_collection( + self, collection_id: str, operations: List[stac_types.PatchOperation], **kwargs + ) -> Optional[stac_types.Collection]: + """Patch a collection following RF6902. + + Args: + collection_id (str): The ID of the collection to patch. + operations (List): List of operations to run on collection. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Collection: The patched collection object. + + """ + collection = await self.database.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=str(kwargs["request"].base_url), + ) + + return CollectionSerializer.db_to_stac( + collection, + kwargs["request"], + extensions=[type(ext).__name__ for ext in self.database.extensions], + ) + @overrides async def delete_collection( self, collection_id: str, **kwargs diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index 2cf880c9..87d5f8a1 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -338,7 +338,7 @@ async def aggregate( datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, filter_lang: Optional[str] = None, - filter: Optional[str] = None, + filter_expr: Optional[str] = None, aggregations: Optional[str] = None, ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, @@ -369,6 +369,7 @@ async def aggregate( "geometry_geohash_grid_frequency_precision": geometry_geohash_grid_frequency_precision, "geometry_geotile_grid_frequency_precision": geometry_geotile_grid_frequency_precision, "datetime_frequency_interval": datetime_frequency_interval, + "datetime": datetime, } if collection_id: @@ -377,11 +378,8 @@ async def aggregate( if intersects: base_args["intersects"] = orjson.loads(unquote_plus(intersects)) - if datetime: - base_args["datetime"] = self._format_datetime_range(datetime) - - if filter: - base_args["filter"] = self.get_filter(filter, filter_lang) + if filter_expr: + base_args["filter"] = self.get_filter(filter_expr, filter_lang) aggregate_request = EsAggregationExtensionPostRequest(**base_args) else: # Workaround for optional path param in POST requests @@ -389,9 +387,9 @@ async def aggregate( collection_id = path.split("/")[2] filter_lang = "cql2-json" - if aggregate_request.filter: - aggregate_request.filter = self.get_filter( - aggregate_request.filter, filter_lang + if aggregate_request.filter_expr: + aggregate_request.filter_expr = self.get_filter( + aggregate_request.filter_expr, filter_lang ) if collection_id: @@ -465,10 +463,10 @@ async def aggregate( detail=f"Aggregation {agg_name} not supported at catalog level", ) - if aggregate_request.filter: + if aggregate_request.filter_expr: try: search = self.database.apply_cql2_filter( - search, aggregate_request.filter + search, aggregate_request.filter_expr ) except Exception as e: raise HTTPException( diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py new file mode 100644 index 00000000..7dbfd3db --- /dev/null +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -0,0 +1,155 @@ +"""patch helpers.""" + +import re +from typing import Any, Dict, Optional, Union + +from pydantic import BaseModel, computed_field, model_validator + +regex = re.compile(r"([^.' ]*:[^.' ]*)\.?") + + +class ESCommandSet: + """Uses dictionary keys to behaviour of ordered set. + + Yields: + str: Elasticsearch commands + """ + + dict_: Dict[str, None] = {} + + def __init__(self): + """Initialise ESCommandSet instance.""" + self.dict_ = {} + + def add(self, value: str): + """Add command. + + Args: + value (str): value to be added + """ + self.dict_[value] = None + + def remove(self, value: str): + """Remove command. + + Args: + value (str): value to be removed + """ + del self.dict_[value] + + def __iter__(self): + """Iterate Elasticsearch commands. + + Yields: + str: Elasticsearch command + """ + yield from self.dict_.keys() + + +def to_es(string: str): + """Convert patch operation key to Elasticsearch key. + + Args: + string (str): string to be converted + + Returns: + _type_: converted string + """ + if matches := regex.findall(string): + for match in set(matches): + string = re.sub(rf"\.?{match}", f"['{match}']", string) + + return string + + +class ElasticPath(BaseModel): + """Converts a JSON path to an Elasticsearch path. + + Args: + path (str): JSON path to be converted. + + """ + + path: str + nest: Optional[str] = None + partition: Optional[str] = None + key: Optional[str] = None + + es_path: Optional[str] = None + es_nest: Optional[str] = None + es_key: Optional[str] = None + + index_: Optional[int] = None + + @model_validator(mode="before") + @classmethod + def validate_model(cls, data: Any): + """Set optional fields from JSON path. + + Args: + data (Any): input data + """ + data["path"] = data["path"].lstrip("/").replace("/", ".") + data["nest"], data["partition"], data["key"] = data["path"].rpartition(".") + + if data["key"].lstrip("-").isdigit() or data["key"] == "-": + data["index_"] = -1 if data["key"] == "-" else int(data["key"]) + data["path"] = f"{data['nest']}[{data['index_']}]" + data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") + + data["es_path"] = to_es(data["path"]) + data["es_nest"] = to_es(data["nest"]) + data["es_key"] = to_es(data["key"]) + + return data + + @computed_field # type: ignore[misc] + @property + def index(self) -> Union[int, str, None]: + """Compute location of path. + + Returns: + str: path index + """ + if self.index_ and self.index_ < 0: + + return f"ctx._source.{self.location}.size() - {-self.index_}" + + return self.index_ + + @computed_field # type: ignore[misc] + @property + def location(self) -> str: + """Compute location of path. + + Returns: + str: path location + """ + return self.nest + self.partition + self.key + + @computed_field # type: ignore[misc] + @property + def es_location(self) -> str: + """Compute location of path. + + Returns: + str: path location + """ + if self.es_key and ":" in self.es_key: + return self.es_nest + self.es_key + return self.es_nest + self.partition + self.es_key + + @computed_field # type: ignore[misc] + @property + def variable_name(self) -> str: + """Variable name for scripting. + + Returns: + str: variable name + """ + if self.index is not None: + return f"{self.location.replace('.','_').replace(':','_')}_{self.index}" + + return ( + f"{self.nest.replace('.','_').replace(':','_')}_{self.key.replace(':','_')}" + ) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index d8c69529..c1e98103 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -3,9 +3,16 @@ This module contains functions for transforming geospatial coordinates, such as converting bounding boxes to polygon representations. """ + from typing import Any, Dict, List, Optional, Set, Union -from stac_fastapi.types.stac import Item +from stac_fastapi.core.models.patch import ElasticPath, ESCommandSet +from stac_fastapi.types.stac import ( + Item, + PatchAddReplaceTest, + PatchOperation, + PatchRemove, +) MAX_LIMIT = 10000 @@ -133,3 +140,181 @@ def dict_deep_update(merge_to: Dict[str, Any], merge_from: Dict[str, Any]) -> No dict_deep_update(merge_to[k], merge_from[k]) else: merge_to[k] = v + + +def merge_to_operations(data: Dict) -> List: + """Convert merge operation to list of RF6902 operations. + + Args: + data: dictionary to convert. + + Returns: + List: list of RF6902 operations. + """ + operations = [] + + for key, value in data.copy().items(): + + if value is None: + operations.append(PatchRemove(op="remove", path=key)) + + elif isinstance(value, dict): + nested_operations = merge_to_operations(value) + + for nested_operation in nested_operations: + nested_operation.path = f"{key}.{nested_operation.path}" + operations.append(nested_operation) + + else: + operations.append(PatchAddReplaceTest(op="add", path=key, value=value)) + + return operations + + +def check_commands( + commands: ESCommandSet, + op: str, + path: ElasticPath, + from_path: bool = False, +) -> None: + """Add Elasticsearch checks to operation. + + Args: + commands (List[str]): current commands + op (str): the operation of script + path (Dict): path of variable to run operation on + from_path (bool): True if path is a from path + + """ + if path.nest: + commands.add( + f"if (!ctx._source.containsKey('{path.nest}'))" + f"{{Debug.explain('{path.nest} does not exist');}}" + ) + + if path.index or op in ["remove", "replace", "test"] or from_path: + commands.add( + f"if (!ctx._source.{path.es_nest}.containsKey('{path.key}'))" + f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}" + ) + + if from_path and path.index is not None: + commands.add( + f"if ((ctx._source.{path.es_location} instanceof ArrayList" + f" && ctx._source.{path.es_location}.size() < {path.index})" + f" || (!(ctx._source.{path.es_location} instanceof ArrayList)" + f" && !ctx._source.{path.es_location}.containsKey('{path.index}')))" + f"{{Debug.explain('{path.path} does not exist');}}" + ) + + +def remove_commands(commands: ESCommandSet, path: ElasticPath) -> None: + """Remove value at path. + + Args: + commands (List[str]): current commands + path (ElasticPath): Path to value to be removed + + """ + if path.index is not None: + commands.add( + f"def {path.variable_name} = ctx._source.{path.es_location}.remove({path.index});" + ) + + else: + commands.add( + f"def {path.variable_name} = ctx._source.{path.es_nest}.remove('{path.key}');" + ) + + +def add_commands( + commands: ESCommandSet, + operation: PatchOperation, + path: ElasticPath, + from_path: ElasticPath, +) -> None: + """Add value at path. + + Args: + commands (List[str]): current commands + operation (PatchOperation): operation to run + path (ElasticPath): path for value to be added + + """ + if from_path is not None: + value = ( + from_path.variable_name + if operation.op == "move" + else f"ctx._source.{from_path.es_path}" + ) + else: + value = operation.json_value + + if path.index is not None: + commands.add( + f"if (ctx._source.{path.es_location} instanceof ArrayList)" + f"{{ctx._source.{path.es_location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}" + f"else{{ctx._source.{path.es_path} = {value}}}" + ) + + else: + commands.add(f"ctx._source.{path.es_path} = {value};") + + +def test_commands( + commands: ESCommandSet, operation: PatchOperation, path: ElasticPath +) -> None: + """Test value at path. + + Args: + commands (List[str]): current commands + operation (PatchOperation): operation to run + path (ElasticPath): path for value to be tested + """ + commands.add( + f"if (ctx._source.{path.es_path} != {operation.json_value})" + f"{{Debug.explain('Test failed `{path.path}` | " + f"{operation.json_value} != ' + ctx._source.{path.es_path});}}" + ) + + +def operations_to_script(operations: List) -> Dict: + """Convert list of operation to painless script. + + Args: + operations: List of RF6902 operations. + + Returns: + Dict: elasticsearch update script. + """ + commands: ESCommandSet = ESCommandSet() + for operation in operations: + path = ElasticPath(path=operation.path) + from_path = ( + ElasticPath(path=operation.from_) if hasattr(operation, "from_") else None + ) + + check_commands(commands=commands, op=operation.op, path=path) + if from_path is not None: + check_commands( + commands=commands, op=operation.op, path=from_path, from_path=True + ) + + if operation.op in ["remove", "move"]: + remove_path = from_path if from_path else path + remove_commands(commands=commands, path=remove_path) + + if operation.op in ["add", "replace", "copy", "move"]: + add_commands( + commands=commands, operation=operation, path=path, from_path=from_path + ) + + if operation.op == "test": + test_commands(commands=commands, operation=operation, path=path) + + source = "".join(commands) + + return { + "source": source, + "lang": "painless", + } diff --git a/stac_fastapi/elasticsearch/setup.py b/stac_fastapi/elasticsearch/setup.py index 7cc31566..a527e60f 100644 --- a/stac_fastapi/elasticsearch/setup.py +++ b/stac_fastapi/elasticsearch/setup.py @@ -32,12 +32,11 @@ description="An implementation of STAC API based on the FastAPI framework with both Elasticsearch and Opensearch.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 0f272218..122dff22 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -10,18 +10,31 @@ import attr from elasticsearch_dsl import Q, Search +from fastapi import HTTPException from starlette.requests import Request from elasticsearch import exceptions, helpers # type: ignore 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.core.utilities import ( + MAX_LIMIT, + bbox2polygon, + merge_to_operations, + operations_to_script, +) from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings from stac_fastapi.elasticsearch.config import ( ElasticsearchSettings as SyncElasticsearchSettings, ) from stac_fastapi.types.errors import ConflictError, NotFoundError -from stac_fastapi.types.stac import Collection, Item +from stac_fastapi.types.links import resolve_links +from stac_fastapi.types.stac import ( + Collection, + Item, + PartialCollection, + PartialItem, + PatchOperation, +) logger = logging.getLogger(__name__) @@ -607,7 +620,7 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] return search @staticmethod - def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]): + def apply_cql2_filter(search: Search, filter_expr: Optional[Dict[str, Any]]): """ Apply a CQL2 filter to an Elasticsearch Search object. @@ -626,8 +639,8 @@ def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]): Search: The modified Search object with the filter applied if a filter is provided, otherwise the original Search object. """ - if _filter is not None: - es_query = filter.to_es(_filter) + if filter_expr is not None: + es_query = filter.to_es(filter_expr) search = search.query(es_query) return search @@ -894,6 +907,129 @@ async def create_item(self, item: Item, refresh: bool = False): f"Item {item_id} in collection {collection_id} already exists" ) + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: PartialItem, + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for merge patching an item following RF7396. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + item (PartialItem): The partial item to be updated. + base_url: (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + operations = merge_to_operations(item) + + return await self.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for json patching an item following RF6902. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + new_item_id = None + new_collection_id = None + script_operations = [] + + for operation in operations: + if operation.path in ["collection", "id"] and operation.op in [ + "add", + "replace", + ]: + + if operation.path == "collection" and collection_id != operation.value: + await self.check_collection_exists(collection_id=operation.value) + new_collection_id = operation.value + + if operation.path == "id" and item_id != operation.value: + new_item_id = operation.value + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + try: + await self.client.update( + index=index_alias_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + script=script, + refresh=True, + ) + + except exceptions.BadRequestError as exc: + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc + + item = await self.get_one_item(collection_id, item_id) + + if new_collection_id: + await self.client.reindex( + body={ + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"}, + "source": { + "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", + "query": {"term": {"id": {"value": item_id}}}, + }, + "script": { + "lang": "painless", + "source": ( + f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');""" + f"""ctx._source.collection = '{new_collection_id}';""" + ), + }, + }, + wait_for_completion=True, + refresh=True, + ) + item["collection"] = new_collection_id + + if new_item_id: + item["id"] = new_item_id + item = await self.prep_create_item(item=item, base_url=base_url) + await self.create_item(item=item, refresh=False) + + if new_collection_id or new_item_id: + + await self.delete_item( + item_id=item_id, + collection_id=collection_id, + refresh=refresh, + ) + + return item + async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -1018,6 +1154,95 @@ async def update_collection( refresh=refresh, ) + async def merge_patch_collection( + self, + collection_id: str, + collection: PartialCollection, + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for merge patching a collection following RF7396. + + Args: + collection_id(str): Id of collection to be patched. + collection (PartialCollection): The partial collection to be updated. + base_url: (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + + Returns: + patched collection. + """ + operations = merge_to_operations(collection) + + return await self.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_collection( + self, + collection_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for json patching a collection following RF6902. + + Args: + collection_id(str): Id of collection to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched collection. + """ + new_collection_id = None + script_operations = [] + + for operation in operations: + if ( + operation.op in ["add", "replace"] + and operation.path == "collection" + and collection_id != operation.value + ): + new_collection_id = operation.value + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + try: + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + script=script, + refresh=True, + ) + + except exceptions.BadRequestError as exc: + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc + + collection = await self.find_collection(collection_id) + + if new_collection_id: + collection["id"] = new_collection_id + collection["links"] = resolve_links([], base_url) + + await self.update_collection( + collection_id=collection_id, + collection=collection, + refresh=False, + ) + + return collection + async def delete_collection(self, collection_id: str, refresh: bool = False): """Delete a collection from the database. diff --git a/stac_fastapi/opensearch/setup.py b/stac_fastapi/opensearch/setup.py index 8522e456..5d583d07 100644 --- a/stac_fastapi/opensearch/setup.py +++ b/stac_fastapi/opensearch/setup.py @@ -32,12 +32,11 @@ description="Opensearch stac-fastapi backend.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 498c9c01..3a2234df 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Iterable, List, Optional, Protocol, Tuple, Type, Union import attr +from fastapi import HTTPException from opensearchpy import exceptions, helpers from opensearchpy.exceptions import TransportError from opensearchpy.helpers.query import Q @@ -17,13 +18,25 @@ from stac_fastapi.core import serializers from stac_fastapi.core.extensions import filter -from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon +from stac_fastapi.core.utilities import ( + MAX_LIMIT, + bbox2polygon, + merge_to_operations, + operations_to_script, +) from stac_fastapi.opensearch.config import ( AsyncOpensearchSettings as AsyncSearchSettings, ) from stac_fastapi.opensearch.config import OpensearchSettings as SyncSearchSettings from stac_fastapi.types.errors import ConflictError, NotFoundError -from stac_fastapi.types.stac import Collection, Item +from stac_fastapi.types.links import resolve_links +from stac_fastapi.types.stac import ( + Collection, + Item, + PartialCollection, + PartialItem, + PatchOperation, +) logger = logging.getLogger(__name__) @@ -926,6 +939,129 @@ async def create_item(self, item: Item, refresh: bool = False): f"Item {item_id} in collection {collection_id} already exists" ) + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: PartialItem, + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for merge patching an item following RF7396. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + item (PartialItem): The partial item to be updated. + base_url: (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + operations = merge_to_operations(item) + + return await self.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for json patching an item following RF6902. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + new_item_id = None + new_collection_id = None + script_operations = [] + + for operation in operations: + if operation.path in ["collection", "id"] and operation.op in [ + "add", + "replace", + ]: + + if operation.path == "collection" and collection_id != operation.value: + await self.check_collection_exists(collection_id=operation.value) + new_collection_id = operation.value + + if operation.path == "id" and item_id != operation.value: + new_item_id = operation.value + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + try: + await self.client.update( + index=index_alias_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + body={"script": script}, + refresh=True, + ) + + except exceptions.RequestError as exc: + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc + + item = await self.get_one_item(collection_id, item_id) + + if new_collection_id: + await self.client.reindex( + body={ + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"}, + "source": { + "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", + "query": {"term": {"id": {"value": item_id}}}, + }, + "script": { + "lang": "painless", + "source": ( + f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');""" + f"""ctx._source.collection = '{new_collection_id}';""" + ), + }, + }, + wait_for_completion=True, + refresh=True, + ) + item["collection"] = new_collection_id + + if new_item_id: + item["id"] = new_item_id + item = await self.prep_create_item(item=item, base_url=base_url) + await self.create_item(item=item, refresh=False) + + if new_collection_id or new_item_id: + + await self.delete_item( + item_id=item_id, + collection_id=collection_id, + refresh=refresh, + ) + + return item + async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -1050,6 +1186,93 @@ async def update_collection( refresh=refresh, ) + async def merge_patch_collection( + self, + collection_id: str, + collection: PartialCollection, + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for merge patching a collection following RF7396. + + Args: + collection_id(str): Id of collection to be patched. + collection (PartialCollection): The partial collection to be updated. + base_url: (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + + Returns: + patched collection. + """ + operations = merge_to_operations(collection) + + return await self.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_collection( + self, + collection_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for json patching a collection following RF6902. + + Args: + collection_id(str): Id of collection to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched collection. + """ + new_collection_id = None + script_operations = [] + + for operation in operations: + if ( + operation.op in ["add", "replace"] + and operation.path == "collection" + and collection_id != operation["value"] + ): + new_collection_id = operation["value"] + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + try: + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + body={"script": script}, + refresh=True, + ) + + except exceptions.RequestError as exc: + + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc + + collection = await self.find_collection(collection_id) + + if new_collection_id: + collection["id"] = new_collection_id + collection["links"] = resolve_links([], base_url) + await self.update_collection( + collection_id=collection_id, collection=collection, refresh=False + ) + + return collection + async def delete_collection(self, collection_id: str, refresh: bool = False): """Delete a collection from the database. diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 64545807..d67236ae 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -28,7 +28,9 @@ "POST /collections", "POST /collections/{collection_id}/items", "PUT /collections/{collection_id}", + "PATCH /collections/{collection_id}", "PUT /collections/{collection_id}/items/{item_id}", + "PATCH /collections/{collection_id}/items/{item_id}", "GET /aggregations", "GET /aggregate", "POST /aggregations", diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index a0867ad3..8d923e0a 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -3,10 +3,12 @@ from typing import Callable import pytest +from fastapi import HTTPException 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 stac_fastapi.types.stac import PatchAddReplaceTest, PatchMoveCopy, PatchRemove from ..conftest import MockRequest, create_item @@ -237,6 +239,351 @@ async def test_update_item(ctx, core_client, txn_client): assert updated_item["properties"]["foo"] == "bar" +@pytest.mark.asyncio +async def test_merge_patch_item_add(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + await txn_client.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item={"properties": {"foo": "bar", "ext:hello": "world"}}, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + assert updated_item["properties"]["foo"] == "bar" + assert updated_item["properties"]["ext:hello"] == "world" + + +@pytest.mark.asyncio +async def test_merge_patch_item_remove(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + await txn_client.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item={"properties": {"gsd": None, "proj:epsg": None}}, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + assert "gsd" not in updated_item["properties"] + assert "proj:epsg" not in updated_item["properties"] + + +@pytest.mark.asyncio +async def test_json_patch_item_add(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/properties/foo", "value": "bar"} + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/properties/ext:hello", "value": "world"} + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/properties/area/1", "value": 10} + ), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert updated_item["properties"]["foo"] == "bar" + assert updated_item["properties"]["ext:hello"] == "world" + assert updated_item["properties"]["area"] == [2500, 10, -200] + + +@pytest.mark.asyncio +async def test_json_patch_item_replace(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/gsd", "value": 100} + ), + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/proj:epsg", "value": 12345} + ), + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/area/1", "value": 50} + ), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert updated_item["properties"]["gsd"] == 100 + assert updated_item["properties"]["proj:epsg"] == 12345 + assert updated_item["properties"]["area"] == [2500, 50] + + +@pytest.mark.asyncio +async def test_json_patch_item_test(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/gsd", "value": 15} + ), + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/proj:epsg", "value": 32756} + ), + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/area/1", "value": -200} + ), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert updated_item["properties"]["gsd"] == 15 + assert updated_item["properties"]["proj:epsg"] == 32756 + assert updated_item["properties"]["area"][1] == -200 + + +@pytest.mark.asyncio +async def test_json_patch_item_move(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/foo", "from": "/properties/gsd"} + ), + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/bar", "from": "/properties/proj:epsg"} + ), + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/area/0", "from": "/properties/area/1"} + ), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert updated_item["properties"]["foo"] == 15 + assert "gsd" not in updated_item["properties"] + assert updated_item["properties"]["bar"] == 32756 + assert "proj:epsg" not in updated_item["properties"] + assert updated_item["properties"]["area"] == [-200, 2500] + + +@pytest.mark.asyncio +async def test_json_patch_item_copy(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/foo", "from": "/properties/gsd"} + ), + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/bar", "from": "/properties/proj:epsg"} + ), + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/area/0", "from": "/properties/area/1"} + ), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert updated_item["properties"]["foo"] == updated_item["properties"]["gsd"] + assert updated_item["properties"]["bar"] == updated_item["properties"]["proj:epsg"] + assert ( + updated_item["properties"]["area"][0] == updated_item["properties"]["area"][1] + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_remove(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "/properties/gsd"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/proj:epsg"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/area/1"}), + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) + + assert "gsd" not in updated_item["properties"] + assert "proj:epsg" not in updated_item["properties"] + assert updated_item["properties"]["area"] == [2500] + + +@pytest.mark.asyncio +async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/platform", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_replace_property_does_not_exists( + ctx, core_client, txn_client +): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/foo", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_remove_property_does_not_exists( + ctx, core_client, txn_client +): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "/properties/foo"}), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_move_property_does_not_exists( + ctx, core_client, txn_client +): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/bar", "from": "/properties/foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_copy_property_does_not_exists( + ctx, core_client, txn_client +): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/bar", "from": "/properties/foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + @pytest.mark.asyncio async def test_update_geometry(ctx, core_client, txn_client): new_coordinates = [ @@ -329,3 +676,281 @@ async def test_landing_page_no_collection_title(ctx, core_client, txn_client, ap for link in landing_page["links"]: if link["href"].split("/")[-1] == ctx.collection["id"]: assert link["title"] + + +@pytest.mark.asyncio +async def test_merge_patch_collection_add(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + + await txn_client.merge_patch_collection( + collection_id=collection_id, + collection={"summaries": {"foo": "bar", "hello": "world"}}, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + assert updated_collection["summaries"]["foo"] == "bar" + assert updated_collection["summaries"]["hello"] == "world" + + +@pytest.mark.asyncio +async def test_merge_patch_collection_remove(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + await txn_client.merge_patch_collection( + collection_id=collection_id, + collection={"summaries": {"gsd": None}}, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_add(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/summaries/foo", "value": "bar"}, + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/summaries/gsd/1", "value": 100}, + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["foo"] == "bar" + assert updated_collection["summaries"]["gsd"] == [30, 100] + + +@pytest.mark.asyncio +async def test_json_patch_collection_replace(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/summaries/gsd", "value": [100]} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["gsd"] == [100] + + +@pytest.mark.asyncio +async def test_json_patch_collection_test(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/summaries/gsd", "value": [30]} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["gsd"] == [30] + + +@pytest.mark.asyncio +async def test_json_patch_collection_move(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "/summaries/bar", "from": "/summaries/gsd"} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["bar"] == [30] + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_copy(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/summaries/foo", "from": "/summaries/gsd"} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert ( + updated_collection["summaries"]["foo"] == updated_collection["summaries"]["gsd"] + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_remove(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "/summaries/gsd"}), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_test_wrong_value(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/summaries/platform", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_replace_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/summaries/foo", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_remove_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "/summaries/foo"}), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_move_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "/summaries/bar", "from": "/summaries/foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_copy_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/summaries/bar", "from": "/summaries/foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) diff --git a/stac_fastapi/tests/data/test_item.json b/stac_fastapi/tests/data/test_item.json index f3d78da8..c311732b 100644 --- a/stac_fastapi/tests/data/test_item.json +++ b/stac_fastapi/tests/data/test_item.json @@ -1,510 +1,514 @@ { - "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", + "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, + "area": [ + 2500, + -200 + ], + "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 904adbbf..f92b36da 100644 --- a/stac_fastapi/tests/resources/test_item.py +++ b/stac_fastapi/tests/resources/test_item.py @@ -483,7 +483,7 @@ async def test_item_search_temporal_window_timezone_get( item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) item_date_before = item_date - timedelta(seconds=1) item_date_before = item_date_before.replace(tzinfo=tzinfo) - item_date_after = item_date + timedelta(seconds=1) + item_date_after = item_date + timedelta(hours=1, seconds=1) item_date_after = item_date_after.replace(tzinfo=tzinfo) params = {