From 0a10b00df782e842f52a4f21147dc46d02c2eb52 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 10 Apr 2024 13:26:19 +0100 Subject: [PATCH 1/5] Adding free-text extension. --- CHANGES.md | 1 + docs/mkdocs.yml | 1 + stac_fastapi/api/stac_fastapi/api/config.py | 2 + .../extensions/third_party/__init__.py | 7 ++- .../third_party/free_text/__init__.py | 5 ++ .../third_party/free_text/free_text.py | 46 +++++++++++++++++++ .../third_party/free_text/request.py | 21 +++++++++ 7 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/free_text.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py diff --git a/CHANGES.md b/CHANGES.md index 0b5d3f192..7ef8fa070 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### Added * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) +* Add Free-text Extension to third party extensions ### Changed diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dff2035ca..9d9eea0fb 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -60,6 +60,7 @@ nav: - version: api/stac_fastapi/extensions/version.md - third_party: - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions.md + - free_text: api/stac_fastapi/extensions/third_party/free_text.md - index: api/stac_fastapi/extensions/third_party/index.md - stac_fastapi.types: - module: api/stac_fastapi/types/index.md diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index e6e4d882a..ccbe4ee14 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -1,4 +1,5 @@ """Application settings.""" + import enum @@ -23,3 +24,4 @@ class AddOns(enum.Enum): """Enumeration of available third party add ons.""" bulk_transaction = "bulk-transaction" + free_text = "free-text" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index ab7349e60..0ae3b0b25 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -1,4 +1,9 @@ """stac_api.extensions.third_party module.""" + from .bulk_transactions import BulkTransactionExtension +from .free_text import FreeTextExtension -__all__ = ("BulkTransactionExtension",) +__all__ = ( + "BulkTransactionExtension", + "FreeTextExtension", +) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/__init__.py new file mode 100644 index 000000000..62c0dee1d --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/__init__.py @@ -0,0 +1,5 @@ +"""Query extension module.""" + +from .free_text import FreeTextExtension + +__all__ = ["FreeTextExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/free_text.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/free_text.py new file mode 100644 index 000000000..06177ef79 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/free_text.py @@ -0,0 +1,46 @@ +"""Free-text extension.""" + +from typing import List, Optional + +import attr +from fastapi import FastAPI + +from stac_fastapi.types.extension import ApiExtension + +from .request import FreeTextExtensionGetRequest, FreeTextExtensionPostRequest + + +@attr.s +class FreeTextExtension(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/README.md + """ + + GET = FreeTextExtensionGetRequest + POST = FreeTextExtensionPostRequest + + conformance_classes: List[str] = attr.ib( + factory=lambda: [ + "https://api.stacspec.org/v1.0.0-rc.1/item-search#free-text", + "https://api.stacspec.org/v1.0.0-rc.1/item-search#advanced-free-text", + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text", + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#advanced-free-text", + "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#free-text", + "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#advanced-free-text", + ] + ) + 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 diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py new file mode 100644 index 000000000..aa4c6d493 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py @@ -0,0 +1,21 @@ +"""Request model for the Free-text extension.""" + +from typing import Any, Dict, Optional + +import attr +from pydantic import BaseModel + +from stac_fastapi.types.search import APIRequest + + +@attr.s +class FreeTextExtensionGetRequest(APIRequest): + """Free-text Extension GET request model.""" + + q: Optional[str] = attr.ib(default=None) + + +class FreeTextExtensionPostRequest(BaseModel): + """Free-text Extension POST request model.""" + + q: Optional[Dict[str, Dict[str, Any]]] From 0b54e63e3add5fc4005d7948536d53819e615448 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 10 Apr 2024 13:29:22 +0100 Subject: [PATCH 2/5] Adding pull request to change log. --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 7ef8fa070..bc31368d8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,7 @@ ### Added * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) -* Add Free-text Extension to third party extensions +* Add Free-text Extension to third party extensions ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) ### Changed From 461cd45ae17d10438c7250bd33f1dbddcdf8cc4c Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 10 Apr 2024 13:50:30 +0100 Subject: [PATCH 3/5] q parameter should be string for post. --- .../stac_fastapi/extensions/third_party/free_text/request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py index aa4c6d493..fb821986c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py @@ -18,4 +18,4 @@ class FreeTextExtensionGetRequest(APIRequest): class FreeTextExtensionPostRequest(BaseModel): """Free-text Extension POST request model.""" - q: Optional[Dict[str, Dict[str, Any]]] + q: Optional[str] = attr.ib(default=None) From 195433c9fbfd4309bd8d2e1fccbb95bf7450c97f Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 10 Apr 2024 14:10:19 +0100 Subject: [PATCH 4/5] Removing unneeded imports. --- .../stac_fastapi/extensions/third_party/free_text/request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py index fb821986c..bac27049d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/free_text/request.py @@ -1,6 +1,6 @@ """Request model for the Free-text extension.""" -from typing import Any, Dict, Optional +from typing import Optional import attr from pydantic import BaseModel From 5be3671474c1b92f9bf8c01070eb11ad5b011197 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 18 Jul 2024 15:17:54 +0200 Subject: [PATCH 5/5] split free-text ext --- .../stac_fastapi/extensions/core/__init__.py | 3 +- .../extensions/core/free_text/__init__.py | 12 +- .../extensions/core/free_text/free_text.py | 58 +++++++- .../extensions/core/free_text/request.py | 38 +++++- .../extensions/tests/test_free_text.py | 128 ++++++++++++++---- 5 files changed, 197 insertions(+), 42 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index fa935d8e8..fe8b6646b 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 1865d64f0..53906bc11 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 be1c389ac..8b61b32df 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 8058fe03a..07aa7be86 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 362d96025..55f253a34 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(