diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index fa935d8e..fe8b6646 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -4,7 +4,7 @@ from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension -from .free_text import FreeTextExtension +from .free_text import FreeTextAdvancedExtension, FreeTextExtension from .pagination import PaginationExtension, TokenPaginationExtension from .query import QueryExtension from .sort import SortExtension @@ -16,6 +16,7 @@ "FieldsExtension", "FilterExtension", "FreeTextExtension", + "FreeTextAdvancedExtension", "PaginationExtension", "QueryExtension", "SortExtension", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py index 1865d64f..53906bc1 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py @@ -1,5 +1,13 @@ """Query extension module.""" -from .free_text import FreeTextConformanceClasses, FreeTextExtension +from .free_text import ( + FreeTextAdvancedExtension, + FreeTextConformanceClasses, + FreeTextExtension, +) -__all__ = ["FreeTextExtension", "FreeTextConformanceClasses"] +__all__ = [ + "FreeTextExtension", + "FreeTextAdvancedExtension", + "FreeTextConformanceClasses", +] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py index be1c389a..8b61b32d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py @@ -8,7 +8,12 @@ from stac_fastapi.types.extension import ApiExtension -from .request import FreeTextExtensionGetRequest, FreeTextExtensionPostRequest +from .request import ( + FreeTextAdvancedExtensionGetRequest, + FreeTextAdvancedExtensionPostRequest, + FreeTextExtensionGetRequest, + FreeTextExtensionPostRequest, +) class FreeTextConformanceClasses(str, Enum): @@ -19,9 +24,9 @@ class FreeTextConformanceClasses(str, Enum): """ # https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic - SEARCH_BASIC = "https://api.stacspec.org/v1.0.0-rc.1/item-search#free-text" - COLLECTIONS_BASIC = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" - ITEMS_BASIC = "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#free-text" + SEARCH = "https://api.stacspec.org/v1.0.0-rc.1/item-search#free-text" + COLLECTIONS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" + ITEMS = "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#free-text" # https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#advanced SEARCH_ADVANCED = ( @@ -42,14 +47,55 @@ class FreeTextExtension(ApiExtension): The Free-text extension adds an additional `q` parameter to `/search` requests which allows the caller to perform free-text queries against STAC metadata. - https://github.com/stac-api-extensions/freetext-search/README.md + https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic """ GET = FreeTextExtensionGetRequest POST = FreeTextExtensionPostRequest - conformance_classes: List[str] = attr.ib() + conformance_classes: List[str] = attr.ib( + default=[ + FreeTextConformanceClasses.SEARCH, + FreeTextConformanceClasses.COLLECTIONS, + FreeTextConformanceClasses.ITEMS, + ] + ) + schema_href: Optional[str] = attr.ib(default=None) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + pass + + +@attr.s +class FreeTextAdvancedExtension(ApiExtension): + """Free-text Extension. + + The Free-text extension adds an additional `q` parameter to `/search` requests which + allows the caller to perform free-text queries against STAC metadata. + + https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#advanced + + """ + + GET = FreeTextAdvancedExtensionGetRequest + POST = FreeTextAdvancedExtensionPostRequest + + conformance_classes: List[str] = attr.ib( + default=[ + FreeTextConformanceClasses.SEARCH_ADVANCED, + FreeTextConformanceClasses.COLLECTIONS_ADVANCED, + FreeTextConformanceClasses.ITEMS_ADVANCED, + ] + ) schema_href: Optional[str] = attr.ib(default=None) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py index 8058fe03..07aa7be8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py @@ -1,31 +1,61 @@ """Request model for the Free-text extension.""" -from typing import Optional +from typing import List, Optional import attr from fastapi import Query from pydantic import BaseModel, Field from typing_extensions import Annotated -from stac_fastapi.types.search import APIRequest +from stac_fastapi.types.search import APIRequest, str2list + + +def _ft_converter( + val: Annotated[ + Optional[str], + Query( + description="Parameter to perform free-text queries against STAC metadata", + json_schema_extra={ + "example": "ocean,coast", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) @attr.s class FreeTextExtensionGetRequest(APIRequest): """Free-text Extension GET request model.""" + q: Optional[List[str]] = attr.ib(default=None, converter=_ft_converter) + + +class FreeTextExtensionPostRequest(BaseModel): + """Free-text Extension POST request model.""" + + q: Optional[List[str]] = Field( + None, + description="Parameter to perform free-text queries against STAC metadata", + ) + + +@attr.s +class FreeTextAdvancedExtensionGetRequest(APIRequest): + """Free-text Extension GET request model.""" + q: Annotated[ Optional[str], Query( description="Parameter to perform free-text queries against STAC metadata", json_schema_extra={ - "example": "item1,item2", + "example": "ocean,coast", }, ), ] = attr.ib(default=None) -class FreeTextExtensionPostRequest(BaseModel): +class FreeTextAdvancedExtensionPostRequest(BaseModel): """Free-text Extension POST request model.""" q: Optional[str] = Field( diff --git a/stac_fastapi/extensions/tests/test_free_text.py b/stac_fastapi/extensions/tests/test_free_text.py index 362d9602..55f253a3 100644 --- a/stac_fastapi/extensions/tests/test_free_text.py +++ b/stac_fastapi/extensions/tests/test_free_text.py @@ -11,7 +11,7 @@ create_post_request_model, create_request_model, ) -from stac_fastapi.extensions.core import FreeTextExtension +from stac_fastapi.extensions.core import FreeTextAdvancedExtension, FreeTextExtension from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient @@ -41,9 +41,7 @@ def test_search_free_text_search(): """Test search endpoints with free-text ext.""" settings = ApiSettings() extensions = [ - FreeTextExtension( - conformance_classes=[FreeTextConformanceClasses.SEARCH_BASIC.value] - ) + FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.SEARCH]) ] api = StacApi( @@ -57,9 +55,8 @@ def test_search_free_text_search(): response = client.get("/conformance") assert response.is_success, response.json() response_dict = response.json() - assert ( - FreeTextConformanceClasses.SEARCH_BASIC.value in response_dict["conformsTo"] - ) + conforms = response_dict["conformsTo"] + assert FreeTextConformanceClasses.SEARCH in conforms # /search - GET, no free-text response = client.get( @@ -78,7 +75,7 @@ def test_search_free_text_search(): }, ) assert response.is_success, response.text - assert response.json() == "ocean,coast" + assert response.json() == ["ocean", "coast"] # /search - POST, no free-text response = client.post( @@ -95,20 +92,98 @@ def test_search_free_text_search(): "/search", json={ "collections": ["test"], + "q": ["ocean", "coast"], + }, + ) + + assert response.is_success, response.text + assert response.json() == ["ocean", "coast"] + + +def test_search_free_text_complete(): + """Test search,collections,items endpoints with free-text ext.""" + settings = ApiSettings() + + free_text = FreeTextExtension( + conformance_classes=[ + FreeTextConformanceClasses.SEARCH, + FreeTextConformanceClasses.ITEMS, + FreeTextConformanceClasses.COLLECTIONS, + ] + ) + + search_get_model = create_get_request_model([free_text]) + search_post_model = create_post_request_model([free_text]) + items_get_model = create_request_model( + "ItemCollectionURI", + base_model=ItemCollectionUri, + mixins=[free_text.GET], + ) + + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=[free_text], + search_get_request_model=search_get_model, + search_post_request_model=search_post_model, + collections_get_request_model=free_text.GET, + items_get_request_model=items_get_model, + ) + with TestClient(api.app) as client: + response = client.get("/conformance") + assert response.is_success, response.json() + response_dict = response.json() + conforms = response_dict["conformsTo"] + assert FreeTextConformanceClasses.SEARCH in conforms + assert FreeTextConformanceClasses.ITEMS in conforms + assert FreeTextConformanceClasses.COLLECTIONS in conforms + + # /search - GET, no free-text + response = client.get( + "/search", + params={"collections": ["test"]}, + ) + assert response.is_success + assert not response.text + + # /search - GET, free-text option + response = client.get( + "/search", + params={ + "collections": ["test"], + "q": "ocean,coast", + }, + ) + assert response.is_success, response.text + assert response.json() == ["ocean", "coast"] + + # /collections - GET, free-text option + response = client.get( + "/collections", + params={ "q": "ocean,coast", }, ) + assert response.is_success, response.text + assert response.json() == ["ocean", "coast"] + # /items - GET, free-text option + response = client.get( + "/collections/test/items", + params={ + "q": "ocean,coast", + }, + ) assert response.is_success, response.text - assert response.json() == "ocean,coast" + assert response.json() == ["ocean", "coast"] -def test_search_free_text_search_advances(): +def test_search_free_text_search_advanced(): """Test search endpoints with free-text ext.""" settings = ApiSettings() extensions = [ - FreeTextExtension( - conformance_classes=[FreeTextConformanceClasses.SEARCH_ADVANCED.value] + FreeTextAdvancedExtension( + conformance_classes=[FreeTextConformanceClasses.SEARCH_ADVANCED] ) ] @@ -123,10 +198,9 @@ def test_search_free_text_search_advances(): response = client.get("/conformance") assert response.is_success, response.json() response_dict = response.json() - assert ( - FreeTextConformanceClasses.SEARCH_ADVANCED.value - in response_dict["conformsTo"] - ) + + conforms = response_dict["conformsTo"] + assert FreeTextConformanceClasses.SEARCH_ADVANCED in conforms # /search - GET, no free-text response = client.get( @@ -170,15 +244,15 @@ def test_search_free_text_search_advances(): assert response.json() == "+ocean,-coast" -def test_search_free_text_complete(): +def test_search_free_text_advanced_complete(): """Test search,collections,items endpoints with free-text ext.""" settings = ApiSettings() - free_text = FreeTextExtension( + free_text = FreeTextAdvancedExtension( conformance_classes=[ - FreeTextConformanceClasses.SEARCH_BASIC.value, - FreeTextConformanceClasses.ITEMS_BASIC.value, - FreeTextConformanceClasses.COLLECTIONS_BASIC.value, + FreeTextConformanceClasses.SEARCH_ADVANCED, + FreeTextConformanceClasses.ITEMS_ADVANCED, + FreeTextConformanceClasses.COLLECTIONS_ADVANCED, ] ) @@ -203,14 +277,10 @@ def test_search_free_text_complete(): response = client.get("/conformance") assert response.is_success, response.json() response_dict = response.json() - assert ( - FreeTextConformanceClasses.SEARCH_BASIC.value in response_dict["conformsTo"] - ) - assert FreeTextConformanceClasses.ITEMS_BASIC.value in response_dict["conformsTo"] - assert ( - FreeTextConformanceClasses.COLLECTIONS_BASIC.value - in response_dict["conformsTo"] - ) + conforms = response_dict["conformsTo"] + assert FreeTextConformanceClasses.SEARCH_ADVANCED in conforms + assert FreeTextConformanceClasses.ITEMS_ADVANCED in conforms + assert FreeTextConformanceClasses.COLLECTIONS_ADVANCED in conforms # /search - GET, no free-text response = client.get(