From 80064f77739ddc1acaafa78cb0d8a11f77ecd33e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 14 Jun 2024 13:28:37 +0200 Subject: [PATCH 01/21] add tests for FieldsExtension impact on validation (#708) Co-authored-by: Jonathan Healy --- stac_fastapi/api/tests/test_app.py | 108 ++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 06257591..829982b5 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -8,10 +8,10 @@ from stac_fastapi.api import app from stac_fastapi.api.models import create_get_request_model, create_post_request_model -from stac_fastapi.extensions.core.filter.filter import FilterExtension +from stac_fastapi.extensions.core import FieldsExtension, FilterExtension from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings -from stac_fastapi.types.core import NumType +from stac_fastapi.types.core import BaseCoreClient, NumType from stac_fastapi.types.search import BaseSearchPostRequest @@ -190,3 +190,107 @@ def get_search( assert landing.status_code == 200, landing.text assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + + +@pytest.mark.parametrize("validate", [True, False]) +def test_fields_extension(validate, TestCoreClient, item_dict): + """Test if fields Parameters are passed correctly.""" + + class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: + raise NotImplementedError + + def all_collections(self, **kwargs) -> stac.Collections: + raise NotImplementedError + + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: + raise NotImplementedError + + def item_collection( + self, + collection_id: str, + bbox: Optional[List[Union[float, int]]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: int = 10, + token: str = None, + **kwargs, + ) -> stac.ItemCollection: + raise NotImplementedError + + test_app = app.StacApi( + settings=ApiSettings(enable_response_models=validate), + client=BadCoreClient(), + search_get_request_model=create_get_request_model([FieldsExtension()]), + search_post_request_model=create_post_request_model([FieldsExtension()]), + extensions=[FieldsExtension()], + ) + + with TestClient(test_app.app) as client: + get_search = client.get( + "/search", + params={"fields": "properties.datetime"}, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + "fields": { + "include": ["properties.datetime"], + "exclude": [], + }, + }, + ) + + assert get_search.status_code == 200, get_search.text + assert post_search.status_code == 200, post_search.text + + test_app = app.StacApi( + settings=ApiSettings(enable_response_models=validate), + client=BadCoreClient(), + search_get_request_model=create_get_request_model([FieldsExtension()]), + search_post_request_model=create_post_request_model([FieldsExtension()]), + extensions=[], + ) + + with TestClient(test_app.app) as client: + get_search = client.get( + "/search", + params={"fields": "properties.datetime"}, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + "fields": { + "include": ["properties.datetime"], + "exclude": [], + }, + }, + ) + if validate: + assert get_search.status_code == 500, ( + get_search.json()["code"] == "ResponseValidationError" + ) + assert post_search.status_code == 500, ( + post_search.json()["code"] == "ResponseValidationError" + ) + else: + assert get_search.status_code == 200, get_search.text + assert post_search.status_code == 200, post_search.text From 8f400e18f361132b92f32d8e3fd62787b3276adc Mon Sep 17 00:00:00 2001 From: Tom Christian <64801328+captaincoordinates@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:58:48 -0700 Subject: [PATCH 02/21] fix(#711): changed default filter language (#712) --- CHANGES.md | 4 + .../extensions/core/filter/request.py | 2 +- stac_fastapi/extensions/tests/test_filter.py | 75 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 stac_fastapi/extensions/tests/test_filter.py diff --git a/CHANGES.md b/CHANGES.md index 49920eb9..3d63fea6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Fixed + +* Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) + ## [3.0.0a3] - 2024-06-13 ### Added diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index dde01530..35a17bf3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -24,4 +24,4 @@ class FilterExtensionPostRequest(BaseModel): filter: Optional[Dict[str, Any]] = None filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql-json") + filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-json") diff --git a/stac_fastapi/extensions/tests/test_filter.py b/stac_fastapi/extensions/tests/test_filter.py new file mode 100644 index 00000000..ca72dc51 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_filter.py @@ -0,0 +1,75 @@ +from typing import Iterator + +import pytest +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.extensions.core import FilterExtension +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + raise NotImplementedError + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + return args[0].model_dump() + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +@pytest.fixture +def client() -> Iterator[TestClient]: + settings = ApiSettings() + extensions = [FilterExtension()] + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=extensions, + search_get_request_model=create_get_request_model(extensions), + search_post_request_model=create_post_request_model(extensions), + ) + with TestClient(api.app) as client: + yield client + + +def test_search_filter_post_filter_lang_default(client: TestClient): + """Test search POST endpoint with filter ext.""" + response = client.post( + "/search", + json={ + "collections": ["test"], + "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["filter_lang"] == "cql2-json" + + +def test_search_filter_post_filter_lang_non_default(client: TestClient): + """Test search POST endpoint with filter ext.""" + filter_lang_value = "cql2-text" + response = client.post( + "/search", + json={ + "collections": ["test"], + "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, + "filter-lang": filter_lang_value, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["filter_lang"] == filter_lang_value From f311dc620d2a11df397f8d95c17e15483b9e302c Mon Sep 17 00:00:00 2001 From: James Fisher <85769594+jamesfisher-gis@users.noreply.github.com> Date: Tue, 25 Jun 2024 09:43:29 -0400 Subject: [PATCH 03/21] Remove the Filter Extension dependency from Aggregation Extension requests (#716) * aggregations type * aggregations type * remove filter extension dependency * linting * update changelog --- CHANGES.md | 4 ++++ .../extensions/core/aggregation/request.py | 18 +++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3d63fea6..ea020992 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,10 @@ * Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) +### Removed + +* Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) + ## [3.0.0a3] - 2024-06-13 ### Added diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index fcab3323..08ebe0cf 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,24 +1,24 @@ """Request model for the Aggregation extension.""" -from typing import List, Optional, Union +from typing import List, Optional import attr -from stac_fastapi.extensions.core.filter.request import ( - FilterExtensionGetRequest, - FilterExtensionPostRequest, +from stac_fastapi.types.search import ( + BaseSearchGetRequest, + BaseSearchPostRequest, + str2list, ) -from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest @attr.s -class AggregationExtensionGetRequest(BaseSearchGetRequest, FilterExtensionGetRequest): +class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Optional[str] = attr.ib(default=None) + aggregations: Optional[str] = attr.ib(default=None, converter=str2list) -class AggregationExtensionPostRequest(BaseSearchPostRequest, FilterExtensionPostRequest): +class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[Union[str, List[str]]] = attr.ib(default=None) + aggregations: Optional[List[str]] = attr.ib(default=None) From 4f410eb8756e253cf224eb4ad8feb533ad2273c1 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 27 Jun 2024 17:59:40 +0200 Subject: [PATCH 04/21] remove middleware stack building to avoid conflict with exception handler (#721) * remove middleware stack building to avoid conflict with exception handler * update changelog --- CHANGES.md | 1 + stac_fastapi/api/stac_fastapi/api/app.py | 7 +----- stac_fastapi/api/tests/test_middleware.py | 30 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ea020992..011a7fde 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,7 @@ ### Removed * Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) +* Removed `add_middleware` method in `StacApi` object and let starlette handle the middleware stack creation [721](https://github.com/stac-utils/stac-fastapi/pull/721) ## [3.0.0a3] - 2024-06-13 diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 5fe7f9d0..b4f5125f 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -439,11 +439,6 @@ def add_route_dependencies( """ return add_route_dependencies(self.app.router.routes, scopes, dependencies) - def add_middleware(self, middleware: Middleware): - """Add a middleware class to the application.""" - self.app.user_middleware.insert(0, middleware) - self.app.middleware_stack = self.app.build_middleware_stack() - def __attrs_post_init__(self): """Post-init hook. @@ -484,7 +479,7 @@ def __attrs_post_init__(self): # add middlewares for middleware in self.middlewares: - self.add_middleware(middleware) + self.app.user_middleware.insert(0, middleware) # customize route dependencies for scopes, dependencies in self.route_dependencies: diff --git a/stac_fastapi/api/tests/test_middleware.py b/stac_fastapi/api/tests/test_middleware.py index 041dc410..00e7f803 100644 --- a/stac_fastapi/api/tests/test_middleware.py +++ b/stac_fastapi/api/tests/test_middleware.py @@ -1,6 +1,8 @@ from unittest import mock import pytest +from fastapi import Request +from fastapi.responses import JSONResponse from starlette.applications import Starlette from starlette.testclient import TestClient @@ -166,3 +168,31 @@ def test_cors_middleware(test_client): resp = test_client.get("/_mgmt/ping", headers={"Origin": "http://netloc"}) assert resp.status_code == 200 assert resp.headers["access-control-allow-origin"] == "*" + + +def test_middleware_stack(): + stac_api = StacApi( + settings=ApiSettings(), client=mock.create_autospec(BaseCoreClient) + ) + + def exception_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=400, + content={"customerrordetail": "yoo", "body": "yo"}, + ) + + class CustomException(Exception): + "Custom Exception" + + pass + + stac_api.app.add_exception_handler(CustomException, exception_handler) + + @stac_api.app.get("/error") + def error_endpoint(): + raise CustomException("got you!") + + with TestClient(stac_api.app) as client: + resp = client.get("/error") + assert resp.status_code == 400 + assert resp.json()["customerrordetail"] == "yoo" From 63097135d72d1afe8731b6138af1974aace477af Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 27 Jun 2024 22:19:43 +0200 Subject: [PATCH 05/21] raise RuntimeError if middleware stack has already been created when initialiazing StacApi (#722) --- stac_fastapi/api/stac_fastapi/api/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index b4f5125f..44a55764 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -478,6 +478,9 @@ def __attrs_post_init__(self): self.app.openapi = self.customize_openapi # add middlewares + if self.middlewares and self.app.middleware_stack is not None: + raise RuntimeError("Cannot add middleware after an application has started") + for middleware in self.middlewares: self.app.user_middleware.insert(0, middleware) From 1916d44397733ff09c247c1578e113ef3f7a2501 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 28 Jun 2024 17:55:13 +0200 Subject: [PATCH 06/21] Release/v3.0.0a4 (#723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0a3 → 3.0.0a4 --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 5 ++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 011a7fde..2e32b581 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0a4] - 2024-06-27 + ### Fixed * Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) @@ -407,7 +409,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a4]: [3.0.0a3]: [3.0.0a2]: [3.0.0a1]: diff --git a/VERSION b/VERSION index 4f22bc78..255dd065 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a3 +3.0.0a4 diff --git a/pyproject.toml b/pyproject.toml index b5675099..fbe51fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0a3" +current_version = "3.0.0a4" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 0ead3026..f182485f 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 0ead3026..f182485f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 0ead3026..f182485f 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" From 270e03d29e3d4369c977812de5a6e731662e4cf0 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 1 Jul 2024 20:37:19 +0200 Subject: [PATCH 07/21] move pagination models to extensions submodule (#717) * move pagination models to extensions submodule * remove models from api * update changelog * update changelog --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 4 +++ stac_fastapi/api/stac_fastapi/api/models.py | 26 -------------- .../extensions/core/pagination/pagination.py | 3 +- .../extensions/core/pagination/request.py | 34 +++++++++++++++++++ .../core/pagination/token_pagination.py | 3 +- 5 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py diff --git a/CHANGES.md b/CHANGES.md index 2e32b581..2b74edbb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Changed + +* moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) + ## [3.0.0a4] - 2024-06-27 ### Fixed diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 2716fe7f..307be14a 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -110,32 +110,6 @@ class ItemCollectionUri(CollectionUri): datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) -class POSTTokenPagination(BaseModel): - """Token pagination model for POST requests.""" - - token: Optional[str] = None - - -@attr.s -class GETTokenPagination(APIRequest): - """Token pagination for GET requests.""" - - token: Optional[str] = attr.ib(default=None) - - -class POSTPagination(BaseModel): - """Page based pagination for POST requests.""" - - page: Optional[str] = None - - -@attr.s -class GETPagination(APIRequest): - """Page based pagination for GET requests.""" - - page: Optional[str] = attr.ib(default=None) - - # Test for ORJSON and use it rather than stdlib JSON where supported if importlib.util.find_spec("orjson") is not None: from fastapi.responses import ORJSONResponse diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py index 296e9ae6..7959b035 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py @@ -5,9 +5,10 @@ import attr from fastapi import FastAPI -from stac_fastapi.api.models import GETPagination, POSTPagination from stac_fastapi.types.extension import ApiExtension +from .request import GETPagination, POSTPagination + @attr.s class PaginationExtension(ApiExtension): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py new file mode 100644 index 00000000..9524ee32 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -0,0 +1,34 @@ +"""Pagination extension request models.""" + +from typing import Optional + +import attr +from pydantic import BaseModel + +from stac_fastapi.types.search import APIRequest + + +@attr.s +class GETTokenPagination(APIRequest): + """Token pagination for GET requests.""" + + token: Optional[str] = attr.ib(default=None) + + +class POSTTokenPagination(BaseModel): + """Token pagination model for POST requests.""" + + token: Optional[str] = None + + +@attr.s +class GETPagination(APIRequest): + """Page based pagination for GET requests.""" + + page: Optional[str] = attr.ib(default=None) + + +class POSTPagination(BaseModel): + """Page based pagination for POST requests.""" + + page: Optional[str] = None diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py index d3fa1039..11ccfb35 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py @@ -5,9 +5,10 @@ import attr from fastapi import FastAPI -from stac_fastapi.api.models import GETTokenPagination, POSTTokenPagination from stac_fastapi.types.extension import ApiExtension +from .request import GETTokenPagination, POSTTokenPagination + @attr.s class TokenPaginationExtension(ApiExtension): From 1062ac44ad433defc765354130e4570f39402ee7 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 1 Jul 2024 20:46:23 +0200 Subject: [PATCH 08/21] remove pagination extension dependency and add request model attributes (#718) * remove pagination extension dependency and add request model attributes * add migration guide --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 16 ++- docs/mkdocs.yml | 2 + docs/src/migrations/v3.0.0.md | 163 +++++++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 35 ++--- stac_fastapi/api/tests/test_api.py | 9 ++ stac_fastapi/api/tests/test_app.py | 71 +++++++++- 6 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 docs/src/migrations/v3.0.0.md diff --git a/CHANGES.md b/CHANGES.md index 2b74edbb..fc4a11e9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,9 +2,23 @@ ## [Unreleased] - TBD +### Added + +* Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for: + - `/collections`: **collections_get_request_model**, default to `EmptyRequest` + - `/collections/{collection_id}`: **collection_get_request_model**, default to `CollectionUri` + - `/collections/{collection_id}/items`: **items_get_request_model**, default to `ItemCollectionUri` + - `/collections/{collection_id}/items/{item_id}`: **item_get_request_model**, default to `ItemUri` + +### Removed + +* Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) +* Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` +* Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) + ### Changed -* moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) +* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) ## [3.0.0a4] - 2024-06-27 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dff2035c..e5326e27 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -74,6 +74,8 @@ nav: - search: api/stac_fastapi/types/search.md - stac: api/stac_fastapi/types/stac.md - version: api/stac_fastapi/types/version.md + - Migration Guides: + - v2.5 -> v3.0: migrations/v3.0.0.md - Performance Benchmarks: benchmarks.html - Development - Contributing: "contributing.md" - Release Notes: "release-notes.md" diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md new file mode 100644 index 00000000..6cbb3605 --- /dev/null +++ b/docs/src/migrations/v3.0.0.md @@ -0,0 +1,163 @@ + +# stac-fastapi v3.0 Migration Guide + +This document aims to help you update your application from **stac-fastapi** 2.5 to 3.0.0. + +## Dependencies + +- **pydantic~=2.0** +- **fastapi>=0.111** +- **stac-pydantic~=3.1** + +Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pydantic v1 to v2 is mostly the one update bringing most breaking changes (see https://docs.pydantic.dev/latest/migration/). + +In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21) + + +## Deprecation + +* the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification. + +* `stac_fastapi.api.config_openapi` method was removed (see https://github.com/stac-utils/stac-fastapi/pull/523) + +* passing `response_class` in `stac_fastapi.api.routes.create_async_endpoint` is now deprecated. The response class now has to be set when registering the endpoint to the application (see https://github.com/stac-utils/stac-fastapi/issues/461) + +* `PostFieldsExtension.filter_fields` property has been removed. + +## Middlewares configuration + +The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). + +```python +# before +class myMiddleware(mainMiddleware): + option1 = option1 + option2 = option2 + +stac = StacApi( + middlewares=[ + myMiddleware, + ] +) + +# now +stac = StacApi( + middlewares=[ + Middleware(myMiddleware, option1, option2), + ] +) +``` + +## Request Models + +In stac-fastapi v2.0, users could already customize both GET/POST search request models. For v3.0, we've added more attributes to enable other endpoints customization: + +- `collections_get_request_model`: GET request model for the `/collections` endpoint (default to `EmptyRequest`) +- `collection_get_request_model`: GET request model for the `/collections/{collection_id}` endpoint (default to `stac_fastapi.api.models.CollectionUri`) +- `items_get_request_model`: GET request model for the `/collections/{collection_id}/items` endpoint (default to `stac_fastapi.api.models.ItemCollectionUri`) +- `item_get_request_model`: GET request model for the `/collections/{collection_id}/items/{item_id}` endpoint (default to `stac_fastapi.api.models.ItemUri`) + +```python +# before +getSearchModel = create_request_model( + model_name="SearchGetRequest", + base_model=BaseSearchGetRequest + extensions=[...], + request_type="GET" +) +stac = StacApi( + search_get_request_model=getSearchModel, + search_post_request_model=..., +) + +# now +@dataclass +class CollectionsRequest(APIRequest): + user: str = Query(...) + +stac = StacApi( + search_get_request_model=getSearchModel, + search_post_request_model=postSearchModel, + collections_get_request_model=CollectionsRequest, + collection_get_request_model=..., + items_get_request_model=..., + item_get_request_model=..., +) +``` + +## Filter extension + +`default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). + +```python +# before +stac = StacApi( + extensions=[ + FieldsExtension() + ] +) + +# now +class PostFieldsExtension(requests.PostFieldsExtension): + include: Optional[Set[str]] = Field( + default_factory=lambda: { + "id", + "type", + "stac_version", + "geometry", + "bbox", + "links", + "assets", + "properties.datetime", + "collection", + } + ) + exclude: Optional[Set[str]] = set() + + +class FieldsExtensionPostRequest(BaseModel): + """Additional fields and schema for the POST request.""" + + fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) + + +class FieldsExtension(FieldsExtensionBase): + """Override the POST model""" + + POST = FieldsExtensionPostRequest + + +from stac_fastapi.api.app import StacApi + +stac = StacApi( + extensions=[ + FieldsExtension() + ] +) +``` + +## Pagination extension + +In stac-fastapi v3.0, we removed the `pagination_extension` attribute in `stac_fastapi.api.app.StacApi`. This attribute was used within the `register_get_item_collection` to update the request model for the `/collections/{collection_id}/items` endpoint. + +It's now up to the user to create the request model and use the `items_get_request_model=` attribute in the StacApi object. + +```python +# before +stac=StacApi( + pagination_extension=TokenPaginationExtension, + extension=[TokenPaginationExtension] +) + +# now +items_get_request_model = create_request_model( + "ItemCollectionURI", + base_model=ItemCollectionUri, + mixins=[TokenPaginationExtension().GET], +) + +stac=StacApi( + extension=[TokenPaginationExtension], + items_get_request_model=items_get_request_model, +) +``` diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 44a55764..a03c5d10 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -18,18 +18,18 @@ from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware from stac_fastapi.api.models import ( + APIRequest, CollectionUri, EmptyRequest, GeoJSONResponse, ItemCollectionUri, ItemUri, - create_request_model, ) from stac_fastapi.api.openapi import update_openapi from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint # TODO: make this module not depend on `stac_fastapi.extensions` -from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension +from stac_fastapi.extensions.core import FieldsExtension from stac_fastapi.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -108,7 +108,10 @@ class StacApi: search_post_request_model: Type[BaseSearchPostRequest] = attr.ib( default=BaseSearchPostRequest ) - pagination_extension = attr.ib(default=TokenPaginationExtension) + collections_get_request_model: Type[APIRequest] = attr.ib(default=EmptyRequest) + collection_get_request_model: Type[APIRequest] = attr.ib(default=CollectionUri) + items_get_request_model: Type[APIRequest] = attr.ib(default=ItemCollectionUri) + item_get_request_model: Type[APIRequest] = attr.ib(default=ItemUri) response_class: Type[Response] = attr.ib(default=JSONResponse) middlewares: List[Middleware] = attr.ib( default=attr.Factory( @@ -211,7 +214,9 @@ def register_get_item(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.get_item, ItemUri), + endpoint=create_async_endpoint( + self.client.get_item, self.item_get_request_model + ), ) def register_post_search(self): @@ -302,7 +307,9 @@ def register_get_collections(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest), + endpoint=create_async_endpoint( + self.client.all_collections, self.collections_get_request_model + ), ) def register_get_collection(self): @@ -329,7 +336,9 @@ def register_get_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.get_collection, CollectionUri), + endpoint=create_async_endpoint( + self.client.get_collection, self.collection_get_request_model + ), ) def register_get_item_collection(self): @@ -338,16 +347,6 @@ def register_get_item_collection(self): Returns: None """ - pagination_extension = self.get_extension(self.pagination_extension) - if pagination_extension is not None: - mixins = [pagination_extension.GET] - else: - mixins = None - request_model = create_request_model( - "ItemCollectionURI", - base_model=ItemCollectionUri, - mixins=mixins, - ) self.router.add_api_route( name="Get ItemCollection", path="/collections/{collection_id}/items", @@ -366,7 +365,9 @@ def register_get_item_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.item_collection, request_model), + endpoint=create_async_endpoint( + self.client.item_collection, self.items_get_request_model + ), ) def register_core(self): diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index d559a377..7db4d9a5 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -2,6 +2,7 @@ from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import ItemCollectionUri, create_request_model from stac_fastapi.extensions.core import ( TokenPaginationExtension, TransactionExtension, @@ -13,6 +14,13 @@ class TestRouteDependencies: @staticmethod def _build_api(**overrides): settings = config.ApiSettings() + + items_get_request_model = create_request_model( + "ItemCollectionURI", + base_model=ItemCollectionUri, + mixins=[TokenPaginationExtension().GET], + ) + return StacApi( **{ "settings": settings, @@ -23,6 +31,7 @@ def _build_api(**overrides): ), TokenPaginationExtension(), ], + "items_get_request_model": items_get_request_model, **overrides, } ) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 829982b5..1076c24e 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,13 +1,19 @@ +from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Union import pytest +from fastapi import Path, Query from fastapi.testclient import TestClient from pydantic import ValidationError from stac_pydantic import api from stac_fastapi.api import app -from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.api.models import ( + APIRequest, + create_get_request_model, + create_post_request_model, +) from stac_fastapi.extensions.core import FieldsExtension, FilterExtension from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings @@ -294,3 +300,66 @@ def item_collection( else: assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + + +def test_request_model(AsyncTestCoreClient): + """Test if request models are passed correctly.""" + + @dataclass + class CollectionsRequest(APIRequest): + user: str = Query(...) + + @dataclass + class CollectionRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + user: str = Query(...) + + @dataclass + class ItemsRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + user: str = Query(...) + + @dataclass + class ItemRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + item_id: str = Path(description="Item ID") + user: str = Query(...) + + test_app = app.StacApi( + settings=ApiSettings(), + client=AsyncTestCoreClient(), + collections_get_request_model=CollectionsRequest, + collection_get_request_model=CollectionRequest, + items_get_request_model=ItemsRequest, + item_get_request_model=ItemRequest, + extensions=[], + ) + + with TestClient(test_app.app) as client: + resp = client.get("/collections") + assert resp.status_code == 400 + + resp = client.get("/collections", params={"user": "Luke"}) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection") + assert resp.status_code == 400 + + resp = client.get("/collections/test_collection", params={"user": "Leia"}) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection/items") + assert resp.status_code == 400 + + resp = client.get( + "/collections/test_collection/items", params={"user": "Obi-Wan"} + ) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection/items/test_item") + assert resp.status_code == 400 + + resp = client.get( + "/collections/test_collection/items/test_item", params={"user": "Chewbacca"} + ) + assert resp.status_code == 200 From b3e7cd0c53d55a64bc4822703bbdac4f065a38e0 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 2 Jul 2024 22:55:29 +0200 Subject: [PATCH 09/21] replace attr with dataclass + fastapi.Query() for GET models (#714) * demonstrate issue 713 * move from attr to dataclass+fastapi.Query() for GET models * update migration --- CHANGES.md | 1 + docs/src/migrations/v3.0.0.md | 44 +++++++++++++++++- stac_fastapi/api/stac_fastapi/api/models.py | 40 ++++++++++------ stac_fastapi/api/tests/test_models.py | 30 ++++++++++-- .../extensions/core/aggregation/request.py | 17 +++++-- .../extensions/core/fields/request.py | 13 ++++-- .../extensions/core/filter/request.py | 12 +++-- .../extensions/core/pagination/request.py | 12 +++-- .../extensions/core/query/request.py | 8 ++-- .../extensions/core/sort/request.py | 13 ++++-- .../extensions/core/transaction.py | 14 +++--- .../extensions/tests/test_aggregation.py | 32 +++++++++++++ stac_fastapi/extensions/tests/test_filter.py | 46 ++++++++++++++++++- .../types/stac_fastapi/types/search.py | 30 ++++++++---- 14 files changed, 253 insertions(+), 59 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index fc4a11e9..df7ae0d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,7 @@ ### Changed +* Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714) * Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) ## [3.0.0a4] - 2024-06-27 diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 6cbb3605..8bc86f94 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -13,7 +13,6 @@ Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pyda In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21) - ## Deprecation * the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification. @@ -24,6 +23,49 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma * `PostFieldsExtension.filter_fields` property has been removed. +## `attr` -> `dataclass` for APIRequest models + +Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`. + +```python +# before +@attr.s +class CollectionModel(APIRequest): + collections: Optional[str] = attr.ib(default=None, converter=str2list) + +# now +@dataclass +class CollectionModel(APIRequest): + collections: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.collections: + self.collections = str2list(self.collections) # type: ignore + +``` + +!!! warning + + if you want to extend a class with a `required` attribute (without default), you will have to write all the attributes to avoid having *non-default* attributes defined after *default* attributes (ref: https://github.com/stac-utils/stac-fastapi/pull/714/files#r1651557338) + + ```python + @dataclass + class A: + value: Annotated[str, Query()] + + # THIS WON'T WORK + @dataclass + class B(A): + another_value: Annotated[str, Query(...)] + + # DO THIS + @dataclass + class B(A): + another_value: Annotated[str, Query(...)] + value: Annotated[str, Query()] + ``` + ## Middlewares configuration The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 307be14a..7a39fe49 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,12 +1,13 @@ """Api request/response models.""" import importlib.util +from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union -import attr -from fastapi import Path +from fastapi import Path, Query from pydantic import BaseModel, create_model from stac_pydantic.shared import BBox +from typing_extensions import Annotated from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.rfc3339 import DateTimeType @@ -37,11 +38,11 @@ def create_request_model( mixins = mixins or [] - models = [base_model] + extension_models + mixins + models = extension_models + mixins + [base_model] # Handle GET requests if all([issubclass(m, APIRequest) for m in models]): - return attr.make_class(model_name, attrs={}, bases=tuple(models)) + return make_dataclass(model_name, [], bases=tuple(models)) # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): @@ -80,34 +81,43 @@ def create_post_request_model( ) -@attr.s # type:ignore +@dataclass class CollectionUri(APIRequest): """Get or delete collection.""" - collection_id: str = attr.ib(default=Path(..., description="Collection ID")) + collection_id: Annotated[str, Path(description="Collection ID")] -@attr.s -class ItemUri(CollectionUri): +@dataclass +class ItemUri(APIRequest): """Get or delete item.""" - item_id: str = attr.ib(default=Path(..., description="Item ID")) + collection_id: Annotated[str, Path(description="Collection ID")] + item_id: Annotated[str, Path(description="Item ID")] -@attr.s +@dataclass class EmptyRequest(APIRequest): """Empty request.""" ... -@attr.s -class ItemCollectionUri(CollectionUri): +@dataclass +class ItemCollectionUri(APIRequest): """Get item collection.""" - limit: int = attr.ib(default=10) - bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) + collection_id: Annotated[str, Path(description="Collection ID")] + limit: Annotated[int, Query()] = 10 + bbox: Annotated[Optional[BBox], Query()] = None + datetime: Annotated[Optional[DateTimeType], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.bbox: + self.bbox = str2bbox(self.bbox) # type: ignore + if self.datetime: + self.datetime = str_to_interval(self.datetime) # type: ignore # Test for ORJSON and use it rather than stdlib JSON where supported diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index cbff0f53..24ed59a1 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -1,6 +1,8 @@ import json import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient from pydantic import ValidationError from stac_fastapi.api.models import create_get_request_model, create_post_request_model @@ -26,13 +28,33 @@ def test_create_get_request_model(): datetime="2020-01-01T00:00:00Z", limit=10, filter="test==test", - # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638 - # hyphen aliases are not properly working - # **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"}, + filter_crs="epsg:4326", + filter_lang="cql2-text", ) assert model.collections == ["test1", "test2"] - # assert model.filter_crs == "epsg:4326" + assert model.filter_crs == "epsg:4326" + + app = FastAPI() + + @app.get("/test") + def route(model=Depends(request_model)): + return model + + with TestClient(app) as client: + resp = client.get( + "/test", + params={ + "collections": "test1,test2", + "filter-crs": "epsg:4326", + "filter-lang": "cql2-text", + }, + ) + assert resp.status_code == 200 + response_dict = resp.json() + assert response_dict["collections"] == ["test1", "test2"] + assert response_dict["filter_crs"] == "epsg:4326" + assert response_dict["filter_lang"] == "cql2-text" @pytest.mark.parametrize( diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 08ebe0cf..325fc55e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,8 +1,11 @@ """Request model for the Aggregation extension.""" +from dataclasses import dataclass from typing import List, Optional -import attr +from fastapi import Query +from pydantic import Field +from typing_extensions import Annotated from stac_fastapi.types.search import ( BaseSearchGetRequest, @@ -11,14 +14,20 @@ ) -@attr.s +@dataclass class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Optional[str] = attr.ib(default=None, converter=str2list) + aggregations: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + super().__post_init__() + if self.aggregations: + self.aggregations = str2list(self.aggregations) # type: ignore class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[List[str]] = attr.ib(default=None) + aggregations: Optional[List[str]] = Field(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index e08572ca..a77539c0 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,10 +1,12 @@ """Request models for the fields extension.""" import warnings +from dataclasses import dataclass from typing import Dict, Optional, Set -import attr +from fastapi import Query from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list @@ -68,11 +70,16 @@ def filter_fields(self) -> Dict: } -@attr.s +@dataclass class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Optional[str] = attr.ib(default=None, converter=str2list) + fields: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.fields: + self.fields = str2list(self.fields) # type: ignore class FieldsExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 35a17bf3..970804b6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,22 +1,24 @@ """Filter extension request models.""" +from dataclasses import dataclass from typing import Any, Dict, Literal, Optional -import attr +from fastapi import Query from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] -@attr.s +@dataclass class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Optional[str] = attr.ib(default=None) - filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-text") + filter: Annotated[Optional[str], Query()] = None + filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = None + filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = "cql2-text" class FilterExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py index 9524ee32..94d98df6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -1,18 +1,20 @@ """Pagination extension request models.""" +from dataclasses import dataclass from typing import Optional -import attr +from fastapi import Query from pydantic import BaseModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest -@attr.s +@dataclass class GETTokenPagination(APIRequest): """Token pagination for GET requests.""" - token: Optional[str] = attr.ib(default=None) + token: Annotated[Optional[str], Query()] = None class POSTTokenPagination(BaseModel): @@ -21,11 +23,11 @@ class POSTTokenPagination(BaseModel): token: Optional[str] = None -@attr.s +@dataclass class GETPagination(APIRequest): """Page based pagination for GET requests.""" - page: Optional[str] = attr.ib(default=None) + page: Annotated[Optional[str], Query()] = None class POSTPagination(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index 7f8425e7..d431b0de 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -1,18 +1,20 @@ """Request model for the Query extension.""" +from dataclasses import dataclass from typing import Any, Dict, Optional -import attr +from fastapi import Query from pydantic import BaseModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest -@attr.s +@dataclass class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Optional[str] = attr.ib(default=None) + query: Annotated[Optional[str], Query()] = None class QueryExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 377067ff..7165d2e3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,20 +1,27 @@ # encoding: utf-8 """Request model for the Sort Extension.""" +from dataclasses import dataclass from typing import List, Optional -import attr +from fastapi import Query from pydantic import BaseModel from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list -@attr.s +@dataclass class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Optional[str] = attr.ib(default=None, converter=str2list) + sortby: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.sortby: + self.sortby = str2list(self.sortby) # type: ignore class SortExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index a1c2391f..27f2291d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,5 +1,6 @@ """Transaction extension.""" +from dataclasses import dataclass from typing import List, Optional, Type, Union import attr @@ -7,6 +8,7 @@ from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.shared import MimeTypes from starlette.responses import JSONResponse, Response +from typing_extensions import Annotated from stac_fastapi.api.models import CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint @@ -15,25 +17,25 @@ from stac_fastapi.types.extension import ApiExtension -@attr.s +@dataclass class PostItem(CollectionUri): """Create Item.""" - item: Union[Item, ItemCollection] = attr.ib(default=Body(None)) + item: Annotated[Union[Item, ItemCollection], Body()] = None -@attr.s +@dataclass class PutItem(ItemUri): """Update Item.""" - item: Item = attr.ib(default=Body(None)) + item: Annotated[Item, Body()] = None -@attr.s +@dataclass class PutCollection(CollectionUri): """Update Collection.""" - collection: Collection = attr.ib(default=Body(None)) + collection: Annotated[Collection, Body()] = None @attr.s diff --git a/stac_fastapi/extensions/tests/test_aggregation.py b/stac_fastapi/extensions/tests/test_aggregation.py index c96e316a..480cc669 100644 --- a/stac_fastapi/extensions/tests/test_aggregation.py +++ b/stac_fastapi/extensions/tests/test_aggregation.py @@ -1,11 +1,15 @@ from typing import Iterator import pytest +from fastapi import Depends, FastAPI from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi from stac_fastapi.extensions.core import AggregationExtension from stac_fastapi.extensions.core.aggregation.client import BaseAggregationClient +from stac_fastapi.extensions.core.aggregation.request import ( + AggregationExtensionGetRequest, +) from stac_fastapi.extensions.core.aggregation.types import ( Aggregation, AggregationCollection, @@ -100,3 +104,31 @@ def core_client() -> DummyCoreClient: @pytest.fixture def aggregations_client() -> BaseAggregationClient: return BaseAggregationClient() + + +def test_agg_get_query(): + """test AggregationExtensionGetRequest model.""" + app = FastAPI() + + @app.get("/test") + def test(query=Depends(AggregationExtensionGetRequest)): + return query + + with TestClient(app) as client: + response = client.get("/test") + assert response.is_success + params = response.json() + assert not params["collections"] + assert not params["aggregations"] + + response = client.get( + "/test", + params={ + "collections": "collection1,collection2", + "aggregations": "prop1,prop2", + }, + ) + assert response.is_success + params = response.json() + assert params["collections"] == ["collection1", "collection2"] + assert params["aggregations"] == ["prop1", "prop2"] diff --git a/stac_fastapi/extensions/tests/test_filter.py b/stac_fastapi/extensions/tests/test_filter.py index ca72dc51..a13fb14c 100644 --- a/stac_fastapi/extensions/tests/test_filter.py +++ b/stac_fastapi/extensions/tests/test_filter.py @@ -21,7 +21,8 @@ def get_item(self, *args, **kwargs): raise NotImplementedError def get_search(self, *args, **kwargs): - raise NotImplementedError + _ = kwargs.pop("request", None) + return kwargs def post_search(self, *args, **kwargs): return args[0].model_dump() @@ -73,3 +74,46 @@ def test_search_filter_post_filter_lang_non_default(client: TestClient): assert response.is_success, response.json() response_dict = response.json() assert response_dict["filter_lang"] == filter_lang_value + + +def test_search_filter_get(client: TestClient): + """Test search GET endpoint with filter ext.""" + response = client.get( + "/search", + params={ + "filter": "id='item_id' AND collection='collection_id'", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert not response_dict["collections"] + assert response_dict["filter"] == "id='item_id' AND collection='collection_id'" + assert not response_dict["filter_crs"] + assert response_dict["filter_lang"] == "cql2-text" + + response = client.get( + "/search", + params={ + "filter": {"op": "=", "args": [{"property": "id"}, "test-item"]}, + "filter-lang": "cql2-json", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert not response_dict["collections"] + assert ( + response_dict["filter"] + == "{'op': '=', 'args': [{'property': 'id'}, 'test-item']}" + ) + assert not response_dict["filter_crs"] + assert response_dict["filter_lang"] == "cql2-json" + + response = client.get( + "/search", + params={ + "collections": "collection1,collection2", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["collections"] == ["collection1", "collection2"] diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cf664734..649a1a8e 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -3,9 +3,10 @@ """ import abc +from dataclasses import dataclass from typing import Dict, List, Optional, Union -import attr +from fastapi import Query from pydantic import PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search @@ -42,7 +43,7 @@ def str2bbox(x: str) -> Optional[BBox]: Limit = Annotated[PositiveInt, AfterValidator(crop)] -@attr.s # type:ignore +@dataclass class APIRequest(abc.ABC): """Generic API Request base class.""" @@ -52,16 +53,27 @@ def kwargs(self) -> Dict: return self.__dict__ -@attr.s +@dataclass class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Optional[str] = attr.ib(default=None, converter=str2list) - ids: Optional[str] = attr.ib(default=None, converter=str2list) - bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - intersects: Optional[str] = attr.ib(default=None) - datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) - limit: Optional[int] = attr.ib(default=10) + collections: Annotated[Optional[str], Query()] = None + ids: Annotated[Optional[str], Query()] = None + bbox: Annotated[Optional[BBox], Query()] = None + intersects: Annotated[Optional[str], Query()] = None + datetime: Annotated[Optional[DateTimeType], Query()] = None + limit: Annotated[Optional[int], Query()] = 10 + + def __post_init__(self): + """convert attributes.""" + if self.collections: + self.collections = str2list(self.collections) # type: ignore + if self.ids: + self.ids = str2list(self.ids) # type: ignore + if self.bbox: + self.bbox = str2bbox(self.bbox) # type: ignore + if self.datetime: + self.datetime = str_to_interval(self.datetime) # type: ignore class BaseSearchPostRequest(Search): From 3c58f0f6e8f26d111dd21f1ffce3442b45b54a70 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 5 Jul 2024 14:47:21 +0200 Subject: [PATCH 10/21] pin minimal version for fastapi-slim (#724) --- stac_fastapi/types/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index c8f2f9df..9fa0ad9e 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -6,7 +6,7 @@ desc = f.read() install_requires = [ - "fastapi-slim", + "fastapi-slim>=0.111.0", "attrs>=23.2.0", "pydantic-settings>=2", "stac_pydantic~=3.1", From dbd04643ba871d032ed5fdf214e4a5b0dc54131d Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 5 Jul 2024 14:58:16 +0200 Subject: [PATCH 11/21] remove FieldsExtension check in StacApi (#725) --- CHANGES.md | 1 + docs/src/migrations/v3.0.0.md | 64 +++++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 21 ++----- stac_fastapi/api/stac_fastapi/api/models.py | 35 ++++------- stac_fastapi/api/tests/test_app.py | 31 ++++++++-- 5 files changed, 110 insertions(+), 42 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index df7ae0d3..22444ca5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ * Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) * Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` * Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) +* Removed use of `FieldsExtension` in `stac_fastapi.api.app.StacApi`. If users use `FieldsExtension`, they would have to handle overpassing the model validation step by returning a `JSONResponse` from the `post_search` and `get_search` client methods. ### Changed diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 8bc86f94..0cb66653 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -203,3 +203,67 @@ stac=StacApi( items_get_request_model=items_get_request_model, ) ``` + + +## Fields extension and model validation + +When using the `Fields` extension, the `/search` endpoint should be able to return `**invalid** STAC Items. This creates an issue when *model validation* is enabled at the application level. + +Previously when adding the `FieldsExtension` to the extensions list and if setting output model validation, we were turning off the validation for both GET/POST `/search` endpoints. This was by-passing validation even when users were not using the `fields` options in requests. + +In `stac-fastapi` v3.0, implementers will have to by-pass the *validation step* at `Client` level by returning `JSONResponse` from the `post_search` and `get_search` client methods. + +```python +# before +class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + +# now +class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if getattr(search_request, "fields", None): + return JSONResponse(content=resp) + + return resp + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if "fields" in kwargs: + return JSONResponse(content=resp) + + return resp + +``` diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index a03c5d10..5148f2ba 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -27,9 +27,6 @@ ) from stac_fastapi.api.openapi import update_openapi from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint - -# TODO: make this module not depend on `stac_fastapi.extensions` -from stac_fastapi.extensions.core import FieldsExtension from stac_fastapi.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -225,15 +222,12 @@ def register_post_search(self): Returns: None """ - fields_ext = self.get_extension(FieldsExtension) self.router.add_api_route( name="Search", path="/search", - response_model=( - (api.ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None - ), + response_model=api.ItemCollection + if self.settings.enable_response_models + else None, responses={ 200: { "content": { @@ -257,15 +251,12 @@ def register_get_search(self): Returns: None """ - fields_ext = self.get_extension(FieldsExtension) self.router.add_api_route( name="Search", path="/search", - response_model=( - (api.ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None - ), + response_model=api.ItemCollection + if self.settings.enable_response_models + else None, responses={ 200: { "content": { diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 7a39fe49..1c2146d4 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,6 +1,5 @@ """Api request/response models.""" -import importlib.util from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union @@ -19,6 +18,12 @@ str_to_interval, ) +try: + import orjson # noqa + from fastapi.responses import ORJSONResponse as JSONResponse +except ImportError: # pragma: nocover + from starlette.responses import JSONResponse + def create_request_model( model_name="SearchGetRequest", @@ -120,29 +125,13 @@ def __post_init__(self): self.datetime = str_to_interval(self.datetime) # type: ignore -# Test for ORJSON and use it rather than stdlib JSON where supported -if importlib.util.find_spec("orjson") is not None: - from fastapi.responses import ORJSONResponse - - class GeoJSONResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/geo+json" - - class JSONSchemaResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/schema+json" - -else: - from starlette.responses import JSONResponse +class GeoJSONResponse(JSONResponse): + """JSON with custom, vendor content-type.""" - class GeoJSONResponse(JSONResponse): - """JSON with custom, vendor content-type.""" + media_type = "application/geo+json" - media_type = "application/geo+json" - class JSONSchemaResponse(JSONResponse): - """JSON with custom, vendor content-type.""" +class JSONSchemaResponse(JSONResponse): + """JSON with custom, vendor content-type.""" - media_type = "application/schema+json" + media_type = "application/schema+json" diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 1076c24e..9fb2c52e 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -11,6 +11,7 @@ from stac_fastapi.api import app from stac_fastapi.api.models import ( APIRequest, + JSONResponse, create_get_request_model, create_post_request_model, ) @@ -206,7 +207,14 @@ class BadCoreClient(BaseCoreClient): def post_search( self, search_request: BaseSearchPostRequest, **kwargs ) -> stac.ItemCollection: - return {"not": "a proper stac item"} + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if getattr(search_request, "fields", None): + return JSONResponse(content=resp) + + return resp def get_search( self, @@ -218,7 +226,14 @@ def get_search( limit: Optional[int] = 10, **kwargs, ) -> stac.ItemCollection: - return {"not": "a proper stac item"} + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if "fields" in kwargs: + return JSONResponse(content=resp) + + return resp def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: raise NotImplementedError @@ -240,6 +255,7 @@ def item_collection( ) -> stac.ItemCollection: raise NotImplementedError + # With FieldsExtension test_app = app.StacApi( settings=ApiSettings(enable_response_models=validate), client=BadCoreClient(), @@ -264,14 +280,18 @@ def item_collection( }, ) + # With or without validation, /search endpoints will always return 200 + # because we have the `FieldsExtension` enabled, so the endpoint + # will avoid the model validation (by returning JSONResponse) assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + # Without FieldsExtension test_app = app.StacApi( settings=ApiSettings(enable_response_models=validate), client=BadCoreClient(), - search_get_request_model=create_get_request_model([FieldsExtension()]), - search_post_request_model=create_post_request_model([FieldsExtension()]), + search_get_request_model=create_get_request_model([]), + search_post_request_model=create_post_request_model([]), extensions=[], ) @@ -290,7 +310,10 @@ def item_collection( }, }, ) + if validate: + # NOTE: the `fields` options will be ignored by fastAPI because it's + # not part of the request model, so the client should not by-pass the validation assert get_search.status_code == 500, ( get_search.json()["code"] == "ResponseValidationError" ) From 494e485c5d619d8c800bc845c391dfdced21fb2a Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 8 Jul 2024 10:05:05 +0200 Subject: [PATCH 12/21] Release/v3.0.0b1 (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0a4 → 3.0.0b1 --- CHANGES.md | 6 +++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 22444ca5..d19e1607 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0b1] - 2024-07-05 + ### Added * Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for: @@ -21,6 +23,7 @@ * Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714) * Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) +* update FastAPI requirement to `>=0.111.0` ## [3.0.0a4] - 2024-06-27 @@ -429,7 +432,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0b1]: [3.0.0a4]: [3.0.0a3]: [3.0.0a2]: diff --git a/VERSION b/VERSION index 255dd065..2daa89b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a4 +3.0.0b1 diff --git a/pyproject.toml b/pyproject.toml index fbe51fbf..d7bffeaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0a4" +current_version = "3.0.0b1" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index f182485f..171dc64b 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index f182485f..171dc64b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index f182485f..171dc64b 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" From 0885f0b401c4831fd26a4137238eb7bbaa5c2b3a Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 9 Jul 2024 17:04:45 +0200 Subject: [PATCH 13/21] move back to attrs (#729) * move back to attrs * update changelog * edit tests * more doc --- CHANGES.md | 9 ++- docs/src/migrations/v3.0.0.md | 78 ++++++++----------- stac_fastapi/api/stac_fastapi/api/models.py | 37 ++++----- stac_fastapi/api/tests/test_app.py | 27 +++---- stac_fastapi/api/tests/test_models.py | 26 ++++--- .../extensions/core/aggregation/request.py | 14 ++-- .../extensions/core/fields/request.py | 11 +-- .../extensions/core/filter/request.py | 14 ++-- .../extensions/core/pagination/request.py | 10 +-- .../extensions/core/query/request.py | 6 +- .../extensions/core/sort/request.py | 12 +-- .../extensions/core/transaction.py | 13 ++-- .../types/stac_fastapi/types/search.py | 36 ++++----- 13 files changed, 136 insertions(+), 157 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d19e1607..649bd2ed 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## [Unreleased] - TBD +## [3.0.0b2] - 2024-07-09 + +### Changed + +* move back to `@attrs` (instead of dataclass) for `APIRequest` (model for GET request) class type [#729](https://github.com/stac-utils/stac-fastapi/pull/729) + ## [3.0.0b1] - 2024-07-05 ### Added @@ -432,7 +438,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0b2]: [3.0.0b1]: [3.0.0a4]: [3.0.0a3]: diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 0cb66653..e9b2ee64 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -23,49 +23,6 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma * `PostFieldsExtension.filter_fields` property has been removed. -## `attr` -> `dataclass` for APIRequest models - -Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`. - -```python -# before -@attr.s -class CollectionModel(APIRequest): - collections: Optional[str] = attr.ib(default=None, converter=str2list) - -# now -@dataclass -class CollectionModel(APIRequest): - collections: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.collections: - self.collections = str2list(self.collections) # type: ignore - -``` - -!!! warning - - if you want to extend a class with a `required` attribute (without default), you will have to write all the attributes to avoid having *non-default* attributes defined after *default* attributes (ref: https://github.com/stac-utils/stac-fastapi/pull/714/files#r1651557338) - - ```python - @dataclass - class A: - value: Annotated[str, Query()] - - # THIS WON'T WORK - @dataclass - class B(A): - another_value: Annotated[str, Query(...)] - - # DO THIS - @dataclass - class B(A): - another_value: Annotated[str, Query(...)] - value: Annotated[str, Query()] - ``` - ## Middlewares configuration The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). @@ -113,9 +70,9 @@ stac = StacApi( ) # now -@dataclass +@attr.s class CollectionsRequest(APIRequest): - user: str = Query(...) + user: Annotated[str, Query(...)] = attr.ib() stac = StacApi( search_get_request_model=getSearchModel, @@ -127,6 +84,37 @@ stac = StacApi( ) ``` +## APIRequest - GET Request Model + +Most of the **GET** endpoints are configured with `stac_fastapi.types.search.APIRequest` base class. + +e.g the BaseSearchGetRequest, default for the `GET - /search` endpoint: + +```python +@attr.s +class BaseSearchGetRequest(APIRequest): + """Base arguments for GET Request.""" + + collections: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) + ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) + limit: Annotated[Optional[int], Query()] = attr.ib(default=10) +``` + +We use [*python attrs*](https://www.attrs.org/en/stable/) to construct those classes. **Type Hint** for each attribute is important and should be defined using `Annotated[{type}, fastapi.Query()]` form. + +```python +@attr.s +class SomeRequest(APIRequest): + user_number: Annotated[Optional[int], Query(alias="user-number")] = attr.ib(default=None) +``` + ## Filter extension `default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 1c2146d4..737089c2 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,8 +1,8 @@ """Api request/response models.""" -from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union +import attr from fastapi import Path, Query from pydantic import BaseModel, create_model from stac_pydantic.shared import BBox @@ -43,11 +43,11 @@ def create_request_model( mixins = mixins or [] - models = extension_models + mixins + [base_model] + models = [base_model] + extension_models + mixins # Handle GET requests if all([issubclass(m, APIRequest) for m in models]): - return make_dataclass(model_name, [], bases=tuple(models)) + return attr.make_class(model_name, attrs={}, bases=tuple(models)) # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): @@ -86,43 +86,38 @@ def create_post_request_model( ) -@dataclass +@attr.s class CollectionUri(APIRequest): """Get or delete collection.""" - collection_id: Annotated[str, Path(description="Collection ID")] + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() -@dataclass +@attr.s class ItemUri(APIRequest): """Get or delete item.""" - collection_id: Annotated[str, Path(description="Collection ID")] - item_id: Annotated[str, Path(description="Item ID")] + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() -@dataclass +@attr.s class EmptyRequest(APIRequest): """Empty request.""" ... -@dataclass +@attr.s class ItemCollectionUri(APIRequest): """Get item collection.""" - collection_id: Annotated[str, Path(description="Collection ID")] - limit: Annotated[int, Query()] = 10 - bbox: Annotated[Optional[BBox], Query()] = None - datetime: Annotated[Optional[DateTimeType], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.bbox: - self.bbox = str2bbox(self.bbox) # type: ignore - if self.datetime: - self.datetime = str_to_interval(self.datetime) # type: ignore + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + limit: Annotated[int, Query()] = attr.ib(default=10) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) class GeoJSONResponse(JSONResponse): diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 9fb2c52e..0ddcb242 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,12 +1,13 @@ -from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Union +import attr import pytest from fastapi import Path, Query from fastapi.testclient import TestClient from pydantic import ValidationError from stac_pydantic import api +from typing_extensions import Annotated from stac_fastapi.api import app from stac_fastapi.api.models import ( @@ -328,25 +329,25 @@ def item_collection( def test_request_model(AsyncTestCoreClient): """Test if request models are passed correctly.""" - @dataclass + @attr.s class CollectionsRequest(APIRequest): - user: str = Query(...) + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class CollectionRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class ItemsRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class ItemRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - item_id: str = Path(description="Item ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() test_app = app.StacApi( settings=ApiSettings(), diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index 24ed59a1..b0c2ad90 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -1,19 +1,20 @@ import json import pytest -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.testclient import TestClient from pydantic import ValidationError from stac_fastapi.api.models import create_get_request_model, create_post_request_model -from stac_fastapi.extensions.core.filter.filter import FilterExtension -from stac_fastapi.extensions.core.sort.sort import SortExtension +from stac_fastapi.extensions.core import FieldsExtension, FilterExtension, SortExtension from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest def test_create_get_request_model(): - extensions = [FilterExtension()] - request_model = create_get_request_model(extensions, BaseSearchGetRequest) + request_model = create_get_request_model( + extensions=[FilterExtension(), FieldsExtension()], + base_model=BaseSearchGetRequest, + ) model = request_model( collections="test1,test2", @@ -35,6 +36,9 @@ def test_create_get_request_model(): assert model.collections == ["test1", "test2"] assert model.filter_crs == "epsg:4326" + with pytest.raises(HTTPException): + request_model(datetime="yo") + app = FastAPI() @app.get("/test") @@ -62,8 +66,10 @@ def route(model=Depends(request_model)): [(None, True), ({"test": "test"}, True), ("test==test", False), ([], False)], ) def test_create_post_request_model(filter, passes): - extensions = [FilterExtension()] - request_model = create_post_request_model(extensions, BaseSearchPostRequest) + request_model = create_post_request_model( + extensions=[FilterExtension(), FieldsExtension()], + base_model=BaseSearchPostRequest, + ) if not passes: with pytest.raises(ValidationError): @@ -100,8 +106,10 @@ def test_create_post_request_model(filter, passes): ], ) def test_create_post_request_model_nested_fields(sortby, passes): - extensions = [SortExtension()] - request_model = create_post_request_model(extensions, BaseSearchPostRequest) + request_model = create_post_request_model( + extensions=[SortExtension()], + base_model=BaseSearchPostRequest, + ) if not passes: with pytest.raises(ValidationError): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 325fc55e..1f4b6a93 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,8 +1,8 @@ """Request model for the Aggregation extension.""" -from dataclasses import dataclass from typing import List, Optional +import attr from fastapi import Query from pydantic import Field from typing_extensions import Annotated @@ -14,17 +14,13 @@ ) -@dataclass +@attr.s class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - super().__post_init__() - if self.aggregations: - self.aggregations = str2list(self.aggregations) # type: ignore + aggregations: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) class AggregationExtensionPostRequest(BaseSearchPostRequest): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index a77539c0..e0c42a57 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,9 +1,9 @@ """Request models for the fields extension.""" import warnings -from dataclasses import dataclass from typing import Dict, Optional, Set +import attr from fastapi import Query from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -70,16 +70,11 @@ def filter_fields(self) -> Dict: } -@dataclass +@attr.s class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.fields: - self.fields = str2list(self.fields) # type: ignore + fields: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) class FieldsExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 970804b6..917f5f08 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,8 +1,8 @@ """Filter extension request models.""" -from dataclasses import dataclass from typing import Any, Dict, Literal, Optional +import attr from fastapi import Query from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -12,13 +12,17 @@ FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] -@dataclass +@attr.s class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Annotated[Optional[str], Query()] = None - filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = None - filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = "cql2-text" + filter: Annotated[Optional[str], Query()] = attr.ib(default=None) + filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = attr.ib( + default=None + ) + filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = attr.ib( + default="cql2-text" + ) class FilterExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py index 94d98df6..66391c7f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -1,8 +1,8 @@ """Pagination extension request models.""" -from dataclasses import dataclass from typing import Optional +import attr from fastapi import Query from pydantic import BaseModel from typing_extensions import Annotated @@ -10,11 +10,11 @@ from stac_fastapi.types.search import APIRequest -@dataclass +@attr.s class GETTokenPagination(APIRequest): """Token pagination for GET requests.""" - token: Annotated[Optional[str], Query()] = None + token: Annotated[Optional[str], Query()] = attr.ib(default=None) class POSTTokenPagination(BaseModel): @@ -23,11 +23,11 @@ class POSTTokenPagination(BaseModel): token: Optional[str] = None -@dataclass +@attr.s class GETPagination(APIRequest): """Page based pagination for GET requests.""" - page: Annotated[Optional[str], Query()] = None + page: Annotated[Optional[str], Query()] = attr.ib(default=None) class POSTPagination(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index d431b0de..5d403a67 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -1,8 +1,8 @@ """Request model for the Query extension.""" -from dataclasses import dataclass from typing import Any, Dict, Optional +import attr from fastapi import Query from pydantic import BaseModel from typing_extensions import Annotated @@ -10,11 +10,11 @@ from stac_fastapi.types.search import APIRequest -@dataclass +@attr.s class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Annotated[Optional[str], Query()] = None + query: Annotated[Optional[str], Query()] = attr.ib(default=None) class QueryExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 7165d2e3..8eeccba0 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,9 +1,8 @@ -# encoding: utf-8 """Request model for the Sort Extension.""" -from dataclasses import dataclass from typing import List, Optional +import attr from fastapi import Query from pydantic import BaseModel from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel @@ -12,16 +11,11 @@ from stac_fastapi.types.search import APIRequest, str2list -@dataclass +@attr.s class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.sortby: - self.sortby = str2list(self.sortby) # type: ignore + sortby: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) class SortExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 27f2291d..4e940a0e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,6 +1,5 @@ """Transaction extension.""" -from dataclasses import dataclass from typing import List, Optional, Type, Union import attr @@ -17,25 +16,25 @@ from stac_fastapi.types.extension import ApiExtension -@dataclass +@attr.s class PostItem(CollectionUri): """Create Item.""" - item: Annotated[Union[Item, ItemCollection], Body()] = None + item: Annotated[Union[Item, ItemCollection], Body()] = attr.ib(default=None) -@dataclass +@attr.s class PutItem(ItemUri): """Update Item.""" - item: Annotated[Item, Body()] = None + item: Annotated[Item, Body()] = attr.ib(default=None) -@dataclass +@attr.s class PutCollection(CollectionUri): """Update Collection.""" - collection: Annotated[Collection, Body()] = None + collection: Annotated[Collection, Body()] = attr.ib(default=None) @attr.s diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 649a1a8e..b8ae23c8 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -2,10 +2,9 @@ """ -import abc -from dataclasses import dataclass from typing import Dict, List, Optional, Union +import attr from fastapi import Query from pydantic import PositiveInt from pydantic.functional_validators import AfterValidator @@ -43,8 +42,8 @@ def str2bbox(x: str) -> Optional[BBox]: Limit = Annotated[PositiveInt, AfterValidator(crop)] -@dataclass -class APIRequest(abc.ABC): +@attr.s +class APIRequest: """Generic API Request base class.""" def kwargs(self) -> Dict: @@ -53,27 +52,20 @@ def kwargs(self) -> Dict: return self.__dict__ -@dataclass +@attr.s class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = None - ids: Annotated[Optional[str], Query()] = None - bbox: Annotated[Optional[BBox], Query()] = None - intersects: Annotated[Optional[str], Query()] = None - datetime: Annotated[Optional[DateTimeType], Query()] = None - limit: Annotated[Optional[int], Query()] = 10 - - def __post_init__(self): - """convert attributes.""" - if self.collections: - self.collections = str2list(self.collections) # type: ignore - if self.ids: - self.ids = str2list(self.ids) # type: ignore - if self.bbox: - self.bbox = str2bbox(self.bbox) # type: ignore - if self.datetime: - self.datetime = str_to_interval(self.datetime) # type: ignore + collections: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) + ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) + limit: Annotated[Optional[int], Query()] = attr.ib(default=10) class BaseSearchPostRequest(Search): From 599742c1d4a88645a6db4c6d9b49b2323400d246 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 9 Jul 2024 18:26:11 +0200 Subject: [PATCH 14/21] =?UTF-8?q?Bump=20version:=203.0.0b1=20=E2=86=92=203?= =?UTF-8?q?.0.0b2=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 2daa89b0..2aa4d8f0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0b1 +3.0.0b2 diff --git a/pyproject.toml b/pyproject.toml index d7bffeaa..9f417299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0b1" +current_version = "3.0.0b2" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 171dc64b..7296e8a9 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 171dc64b..7296e8a9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 171dc64b..7296e8a9 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" From c55c2537728f4df49f5235cc49574f90f8c709b1 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 17 Jul 2024 11:09:34 +0200 Subject: [PATCH 15/21] add description and examples (#734) * add description and examples * update docs * update changelog * Update stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py --- CHANGES.md | 2 + docs/src/migrations/v3.0.0.md | 32 +++- stac_fastapi/api/stac_fastapi/api/models.py | 10 +- .../extensions/core/aggregation/request.py | 18 ++- .../extensions/core/fields/request.py | 23 ++- .../extensions/core/filter/request.py | 64 ++++++-- .../extensions/core/query/query.py | 2 +- .../extensions/core/query/request.py | 20 ++- .../extensions/core/sort/request.py | 31 +++- .../types/stac_fastapi/types/search.py | 138 ++++++++++++++++-- 10 files changed, 293 insertions(+), 47 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 649bd2ed..1cc1773c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +* add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) + ## [3.0.0b2] - 2024-07-09 ### Changed diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index e9b2ee64..f781687c 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -95,14 +95,12 @@ e.g the BaseSearchGetRequest, default for the `GET - /search` endpoint: class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list - ) - ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + collections: Optional[List[str]] = attr.ib(default=None, converter=_collection_converter) + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) limit: Annotated[Optional[int], Query()] = attr.ib(default=10) ``` @@ -115,6 +113,26 @@ class SomeRequest(APIRequest): user_number: Annotated[Optional[int], Query(alias="user-number")] = attr.ib(default=None) ``` +Note: when an attribute has a `converter` (e.g `_ids_converter`), the **Type Hint** should be defined directly in the converter: + +```python +def _ids_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of Item ids to return.", + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + +@attr.s +class BaseSearchGetRequest(APIRequest): + """Base arguments for GET Request.""" + + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) +``` + ## Filter extension `default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 737089c2..5a239b9f 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -14,8 +14,8 @@ APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, - str2bbox, - str_to_interval, + _bbox_converter, + _datetime_converter, ) try: @@ -114,9 +114,9 @@ class ItemCollectionUri(APIRequest): collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() limit: Annotated[int, Query()] = attr.ib(default=10) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 1f4b6a93..4e72e000 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -14,16 +14,26 @@ ) +def _agg_converter( + val: Annotated[ + Optional[str], + Query(description="A list of aggregations to compute and return."), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list - ) + aggregations: Optional[List[str]] = attr.ib(default=None, converter=_agg_converter) class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[List[str]] = Field(default=None) + aggregations: Optional[List[str]] = Field( + default=None, + description="A list of aggregations to compute and return.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index e0c42a57..d3737ea4 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,7 +1,7 @@ """Request models for the fields extension.""" import warnings -from typing import Dict, Optional, Set +from typing import Dict, List, Optional, Set import attr from fastapi import Query @@ -70,14 +70,31 @@ def filter_fields(self) -> Dict: } +def _fields_converter( + val: Annotated[ + Optional[str], + Query( + description="Include or exclude fields from items body.", + json_schema_extra={ + "example": "properties.datetime", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + fields: Optional[List[str]] = attr.ib(default=None, converter=_fields_converter) class FieldsExtensionPostRequest(BaseModel): """Additional fields and schema for the POST request.""" - fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) + fields: Optional[PostFieldsExtension] = Field( + PostFieldsExtension(), + description="Include or exclude fields from items body.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 917f5f08..30ac011b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -16,18 +16,62 @@ class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Annotated[Optional[str], Query()] = attr.ib(default=None) - filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = attr.ib( - default=None - ) - filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = attr.ib( - default="cql2-text" - ) + filter: Annotated[ + Optional[str], + Query( + description="""A CQL filter expression for filtering items.\n +Supports `CQL-JSON` as defined in https://portal.ogc.org/files/96288\n +Remember to URL encode the CQL-JSON if using GET""", + json_schema_extra={ + "example": "id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'", # noqa: E501 + }, + ), + ] = attr.ib(default=None) + filter_crs: Annotated[ + Optional[str], + Query( + alias="filter-crs", + description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 + ), + ] = attr.ib(default=None) + filter_lang: Annotated[ + Optional[FilterLang], + Query( + alias="filter-lang", + description="The CQL filter encoding that the 'filter' value uses.", + ), + ] = attr.ib(default="cql2-text") class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" - filter: Optional[Dict[str, Any]] = None - filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-json") + filter: Optional[Dict[str, Any]] = Field( + default=None, + description="A CQL filter expression for filtering items.", + json_schema_extra={ + "example": { + "op": "and", + "args": [ + { + "op": "=", + "args": [ + {"property": "id"}, + "LC08_L1TP_060247_20180905_20180912_01_T1_L1TP", + ], + }, + {"op": "=", "args": [{"property": "collection"}, "landsat8_l1tp"]}, + ], + }, + }, + ) + filter_crs: Optional[str] = Field( + alias="filter-crs", + default=None, + description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 + ) + filter_lang: Optional[FilterLang] = Field( + alias="filter-lang", + default="cql2-json", + description="The CQL filter encoding that the 'filter' value uses.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py index dcb16206..472c385b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py @@ -17,7 +17,7 @@ class QueryExtension(ApiExtension): The Query extension adds an additional `query` parameter to `/search` requests which allows the caller to perform queries against item metadata (ex. find all images with cloud cover less than 15%). - https://github.com/radiantearth/stac-api-spec/blob/master/item-search/README.md#query + https://github.com/stac-api-extensions/query """ GET = QueryExtensionGetRequest diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index 5d403a67..ad7f461c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -4,7 +4,7 @@ import attr from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest @@ -14,10 +14,24 @@ class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Annotated[Optional[str], Query()] = attr.ib(default=None) + query: Annotated[ + Optional[str], + Query( + description="Allows additional filtering based on the properties of Item objects", # noqa: E501 + json_schema_extra={ + "example": '{"eo:cloud_cover": {"gte": 95}}', + }, + ), + ] = attr.ib(default=None) class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] = None + query: Optional[Dict[str, Dict[str, Any]]] = Field( + None, + description="Allows additional filtering based on the properties of Item objects", # noqa: E501 + json_schema_extra={ + "example": {"eo:cloud_cover": {"gte": 95}}, + }, + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 8eeccba0..e1c22eea 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -4,21 +4,46 @@ import attr from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list +def _sort_converter( + val: Annotated[ + Optional[str], + Query( + description="An array of property names, prefixed by either '+' for ascending or '-' for descending. If no prefix is provided, '+' is assumed.", # noqa: E501 + json_schema_extra={ + "example": "-gsd,-datetime", + }, + ), + ], +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + sortby: Optional[List[str]] = attr.ib(default=None, converter=_sort_converter) class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] = None + sortby: Optional[List[PostSortModel]] = Field( + None, + description="An array of property (field) names, and direction in form of '{'field': '', 'direction':''}'", # noqa: E501 + json_schema_extra={ + "example": [ + { + "field": "properties.created", + "direction": "asc", + } + ], + }, + ) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index b8ae23c8..064ae10c 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -6,7 +6,7 @@ import attr from fastapi import Query -from pydantic import PositiveInt +from pydantic import Field, PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search from stac_pydantic.shared import BBox @@ -23,11 +23,13 @@ def crop(v: PositiveInt) -> PositiveInt: return v -def str2list(x: str) -> Optional[List]: +def str2list(x: str) -> Optional[List[str]]: """Convert string to list base on , delimiter.""" if x: return x.split(",") + return None + def str2bbox(x: str) -> Optional[BBox]: """Convert string to BBox based on , delimiter.""" @@ -36,6 +38,68 @@ def str2bbox(x: str) -> Optional[BBox]: assert len(t) == 4 return t + return None + + +def _collection_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of collection Ids to search for items.", + json_schema_extra={ + "example": "collection1,collection2", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + +def _ids_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of Item ids to return.", + json_schema_extra={ + "example": "item1,item2", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + +def _bbox_converter( + val: Annotated[ + Optional[str], + Query( + description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 + json_schema_extra={ + "example": "-175.05,-85.05,175.05,85.05", + }, + ), + ] = None, +) -> Optional[BBox]: + return str2bbox(val) + + +def _datetime_converter( + val: Annotated[ + Optional[str], + Query( + description="""Only return items that have a temporal property that intersects this value.\n +Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 + openapi_examples={ + "datetime": {"value": "2018-02-12T23:20:50Z"}, + "closed-interval": {"value": "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"}, + "open-interval-from": {"value": "2018-02-12T00:00:00Z/.."}, + "open-interval-to": {"value": "../2018-03-18T12:31:12Z"}, + }, + ), + ] = None, +): + return str_to_interval(val) + # Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 NumType = Union[float, int] @@ -56,19 +120,71 @@ def kwargs(self) -> Dict: class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list + collections: Optional[List[str]] = attr.ib( + default=None, converter=_collection_converter ) - ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) - intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) + intersects: Annotated[ + Optional[str], + Query( + description="""Only return items intersecting this GeoJSON Geometry. Mutually exclusive with **bbox**. \n +*Remember to URL encode the GeoJSON geometry when using GET request*.""", # noqa: E501 + openapi_examples={ + "madrid": { + "value": { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-3.8549260500072933, 40.54923557897152], + [-3.8549260500072933, 40.29428000041938], + [-3.516597069715033, 40.29428000041938], + [-3.516597069715033, 40.54923557897152], + [-3.8549260500072933, 40.54923557897152], + ] + ], + "type": "Polygon", + }, + }, + }, + "new-york": { + "value": { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-74.50117532354284, 41.128266394414055], + [-74.50117532354284, 40.35633909727355], + [-73.46713183168603, 40.35633909727355], + [-73.46713183168603, 41.128266394414055], + [-74.50117532354284, 41.128266394414055], + ] + ], + "type": "Polygon", + }, + }, + }, + }, + ), + ] = attr.ib(default=None) + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) - limit: Annotated[Optional[int], Query()] = attr.ib(default=10) + limit: Annotated[ + Optional[int], + Query( + description="Limits the number of results that are included in each page of the response." # noqa: E501 + ), + ] = attr.ib(default=10) class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" - limit: Optional[Limit] = 10 + limit: Optional[Limit] = Field( + 10, + description="Limits the number of results that are included in each page of the response.", # noqa: E501 + ) From fe4d0dfb9812ec624b5af8bb10e32621b48e090a Mon Sep 17 00:00:00 2001 From: rhysrevans3 <34507919+rhysrevans3@users.noreply.github.com> Date: Fri, 19 Jul 2024 10:11:53 +0100 Subject: [PATCH 16/21] Adding free-text extension. (#655) * Adding free-text extension. * Adding pull request to change log. * q parameter should be string for post. * Removing unneeded imports. * split free-text ext --------- Co-authored-by: vincentsarago --- CHANGES.md | 6 + docs/mkdocs.yml | 4 + stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../stac_fastapi/extensions/core/__init__.py | 3 + .../extensions/core/free_text/__init__.py | 13 + .../extensions/core/free_text/free_text.py | 110 ++++++ .../extensions/core/free_text/request.py | 64 ++++ .../extensions/tests/test_free_text.py | 322 ++++++++++++++++++ 8 files changed, 523 insertions(+) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py create mode 100644 stac_fastapi/extensions/tests/test_free_text.py diff --git a/CHANGES.md b/CHANGES.md index 1cc1773c..939471bb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,14 @@ ## [Unreleased] - TBD +### Changed + * add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) +### Added + +* Add Free-text Extension to third party extensions ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) + ## [3.0.0b2] - 2024-07-09 ### Changed diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e5326e27..79af024a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -36,6 +36,10 @@ nav: - core: - module: api/stac_fastapi/extensions/core/index.md - context: api/stac_fastapi/extensions/core/context.md + - free_text: + - module: api/stac_fastapi/extensions/core/free_text/index.md + - free_text: api/stac_fastapi/extensions/core/free_text/free_text.md + - request: api/stac_fastapi/extensions/core/free_text/request.md - filter: - module: api/stac_fastapi/extensions/core/filter/index.md - filter: api/stac_fastapi/extensions/core/filter/filter.md diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 20a7b4af..74a1c731 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -19,6 +19,7 @@ class ApiExtensions(enum.Enum): sort = "sort" transaction = "transaction" aggregation = "aggregation" + free_text = "free-text" class AddOns(enum.Enum): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 7e29e1fd..fe8b6646 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -4,6 +4,7 @@ from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension +from .free_text import FreeTextAdvancedExtension, FreeTextExtension from .pagination import PaginationExtension, TokenPaginationExtension from .query import QueryExtension from .sort import SortExtension @@ -14,6 +15,8 @@ "ContextExtension", "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 new file mode 100644 index 00000000..53906bc1 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py @@ -0,0 +1,13 @@ +"""Query extension module.""" + +from .free_text import ( + FreeTextAdvancedExtension, + FreeTextConformanceClasses, + FreeTextExtension, +) + +__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 new file mode 100644 index 00000000..8b61b32d --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/free_text.py @@ -0,0 +1,110 @@ +"""Free-text extension.""" + +from enum import Enum +from typing import List, Optional + +import attr +from fastapi import FastAPI + +from stac_fastapi.types.extension import ApiExtension + +from .request import ( + FreeTextAdvancedExtensionGetRequest, + FreeTextAdvancedExtensionPostRequest, + FreeTextExtensionGetRequest, + FreeTextExtensionPostRequest, +) + + +class FreeTextConformanceClasses(str, Enum): + """Conformance classes for the Free-Text extension. + + See https://github.com/stac-api-extensions/freetext-search + + """ + + # https://github.com/stac-api-extensions/freetext-search?tab=readme-ov-file#basic + 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 = ( + "https://api.stacspec.org/v1.0.0-rc.1/item-search#advanced-free-text" + ) + COLLECTIONS_ADVANCED = ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#advanced-free-text" + ) + ITEMS_ADVANCED = ( + "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features#advanced-free-text" + ) + + +@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?tab=readme-ov-file#basic + + """ + + GET = FreeTextExtensionGetRequest + POST = FreeTextExtensionPostRequest + + 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: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + pass 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 new file mode 100644 index 00000000..07aa7be8 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py @@ -0,0 +1,64 @@ +"""Request model for the Free-text extension.""" + +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, 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": "ocean,coast", + }, + ), + ] = attr.ib(default=None) + + +class FreeTextAdvancedExtensionPostRequest(BaseModel): + """Free-text Extension POST request model.""" + + q: Optional[str] = Field( + None, + description="Parameter to perform free-text queries against STAC metadata", + ) diff --git a/stac_fastapi/extensions/tests/test_free_text.py b/stac_fastapi/extensions/tests/test_free_text.py new file mode 100644 index 00000000..55f253a3 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_free_text.py @@ -0,0 +1,322 @@ +# noqa: E501 +"""test freetext extension.""" + + +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import ( + ItemCollectionUri, + create_get_request_model, + create_post_request_model, + create_request_model, +) +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 + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + return kwargs.pop("q", None) + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + return kwargs.pop("q", None) + + def post_search(self, *args, **kwargs): + return args[0].q + + def item_collection(self, *args, **kwargs): + return kwargs.pop("q", None) + + +def test_search_free_text_search(): + """Test search endpoints with free-text ext.""" + settings = ApiSettings() + extensions = [ + FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.SEARCH]) + ] + + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=extensions, + search_get_request_model=create_get_request_model(extensions), + search_post_request_model=create_post_request_model(extensions), + ) + 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 + + # /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"] + + # /search - POST, no free-text + response = client.post( + "/search", + json={ + "collections": ["test"], + }, + ) + assert response.is_success + assert not response.text + + # /search - POST, free-text option + response = client.post( + "/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"] + + +def test_search_free_text_search_advanced(): + """Test search endpoints with free-text ext.""" + settings = ApiSettings() + extensions = [ + FreeTextAdvancedExtension( + conformance_classes=[FreeTextConformanceClasses.SEARCH_ADVANCED] + ) + ] + + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=extensions, + search_get_request_model=create_get_request_model(extensions), + search_post_request_model=create_post_request_model(extensions), + ) + 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_ADVANCED 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" + + # /search - POST, no free-text + response = client.post( + "/search", + json={ + "collections": ["test"], + }, + ) + assert response.is_success + assert not response.text + + # /search - POST, free-text option + response = client.post( + "/search", + json={ + "collections": ["test"], + "q": "+ocean,-coast", + }, + ) + + assert response.is_success, response.text + assert response.json() == "+ocean,-coast" + + +def test_search_free_text_advanced_complete(): + """Test search,collections,items endpoints with free-text ext.""" + settings = ApiSettings() + + free_text = FreeTextAdvancedExtension( + conformance_classes=[ + FreeTextConformanceClasses.SEARCH_ADVANCED, + FreeTextConformanceClasses.ITEMS_ADVANCED, + FreeTextConformanceClasses.COLLECTIONS_ADVANCED, + ] + ) + + 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_ADVANCED in conforms + assert FreeTextConformanceClasses.ITEMS_ADVANCED in conforms + assert FreeTextConformanceClasses.COLLECTIONS_ADVANCED 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" From 4df4947b9822271b0912abe766234edb26a78a59 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 23 Jul 2024 00:35:11 +0200 Subject: [PATCH 17/21] Feature/add collection search extension V2 (#736) * sketch * sketch * fix * set limit to 10 * Update CHANGES.md --- CHANGES.md | 4 +- stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../stac_fastapi/extensions/core/__init__.py | 2 + .../core/collection_search/__init__.py | 5 + .../collection_search/collection_search.py | 66 +++++++ .../core/collection_search/request.py | 27 +++ .../tests/test_collection_search.py | 181 ++++++++++++++++++ 7 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py create mode 100644 stac_fastapi/extensions/tests/test_collection_search.py diff --git a/CHANGES.md b/CHANGES.md index 939471bb..fbc15e50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,8 +7,8 @@ * add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) ### Added - -* Add Free-text Extension to third party extensions ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) +* Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) +* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736)) ## [3.0.0b2] - 2024-07-09 diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 74a1c731..275159a0 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -19,6 +19,7 @@ class ApiExtensions(enum.Enum): sort = "sort" transaction = "transaction" aggregation = "aggregation" + collection_search = "collection-search" free_text = "free-text" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index fe8b6646..385bd902 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,6 +1,7 @@ """stac_api.extensions.core module.""" from .aggregation import AggregationExtension +from .collection_search import CollectionSearchExtension from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension @@ -22,4 +23,5 @@ "SortExtension", "TokenPaginationExtension", "TransactionExtension", + "CollectionSearchExtension", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py new file mode 100644 index 00000000..f919491d --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py @@ -0,0 +1,5 @@ +"""Collection-Search extension module.""" + +from .collection_search import CollectionSearchExtension, ConformanceClasses + +__all__ = ["CollectionSearchExtension", "ConformanceClasses"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py new file mode 100644 index 00000000..aac81205 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py @@ -0,0 +1,66 @@ +"""Collection-Search extension.""" + +from enum import Enum +from typing import List, Optional + +import attr +from fastapi import FastAPI + +from stac_fastapi.types.extension import ApiExtension + +from .request import CollectionSearchExtensionGetRequest + + +class ConformanceClasses(str, Enum): + """Conformance classes for the Collection-Search extension. + + See + https://github.com/stac-api-extensions/collection-search + """ + + COLLECTIONSEARCH = "https://api.stacspec.org/v1.0.0-rc.1/collection-search" + BASIS = "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + FREETEXT = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" + FILTER = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter" + QUERY = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query" + SORT = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort" + FIELDS = "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields" + + +@attr.s +class CollectionSearchExtension(ApiExtension): + """Collection-Search Extension. + + The Collection-Search extension adds functionality to the `GET - /collections` + endpoint which allows the caller to include or exclude specific from the API + response. + Registering this extension with the application has the added effect of + removing the `ItemCollection` response model from the `/search` endpoint, as + the Fields extension allows the API to return potentially invalid responses + by excluding fields which are required by the STAC spec, such as geometry. + + https://github.com/stac-api-extensions/collection-search + + Attributes: + conformance_classes (list): Defines the list of conformance classes for + the extension + """ + + GET = CollectionSearchExtensionGetRequest + POST = None + + conformance_classes: List[str] = attr.ib( + default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS] + ) + schema_href: Optional[str] = attr.ib(default=None) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app (fastapi.FastAPI): target FastAPI application. + + Returns: + None + """ + pass diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py new file mode 100644 index 00000000..663f488d --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -0,0 +1,27 @@ +"""Request models for the Collection-Search extension.""" + +from typing import Optional + +import attr +from fastapi import Query +from stac_pydantic.shared import BBox +from typing_extensions import Annotated + +from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.search import APIRequest, _bbox_converter, _datetime_converter + + +@attr.s +class CollectionSearchExtensionGetRequest(APIRequest): + """Basics additional Collection-Search parameters for the GET request.""" + + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter + ) + limit: Annotated[ + Optional[int], + Query( + description="Limits the number of results that are included in each page of the response." # noqa: E501 + ), + ] = attr.ib(default=10) diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py new file mode 100644 index 00000000..856c5b03 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -0,0 +1,181 @@ +import json +from urllib.parse import quote_plus + +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import create_request_model +from stac_fastapi.extensions.core import CollectionSearchExtension +from stac_fastapi.extensions.core.collection_search import ConformanceClasses +from stac_fastapi.extensions.core.collection_search.request import ( + CollectionSearchExtensionGetRequest, +) +from stac_fastapi.extensions.core.fields.request import FieldsExtensionGetRequest +from stac_fastapi.extensions.core.filter.request import FilterExtensionGetRequest +from stac_fastapi.extensions.core.free_text.request import FreeTextExtensionGetRequest +from stac_fastapi.extensions.core.query.request import QueryExtensionGetRequest +from stac_fastapi.extensions.core.sort.request import SortExtensionGetRequest +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + _ = kwargs.pop("request", None) + return kwargs + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + return args[0].model_dump() + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +def test_collection_search_extension_default(): + """Test /collections endpoint with collection-search ext.""" + api = StacApi( + settings=ApiSettings(), + client=DummyCoreClient(), + extensions=[CollectionSearchExtension()], + collections_get_request_model=CollectionSearchExtensionGetRequest, + ) + with TestClient(api.app) as client: + response = client.get("/conformance") + assert response.is_success, response.json() + response_dict = response.json() + assert ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search" + in response_dict["conformsTo"] + ) + assert ( + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + in response_dict["conformsTo"] + ) + + response = client.get("/collections") + assert response.is_success, response.json() + response_dict = response.json() + assert "bbox" in response_dict + assert "datetime" in response_dict + assert "limit" in response_dict + + response = client.get( + "/collections", + params={ + "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z", + "bbox": "-175.05,-85.05,175.05,85.05", + "limit": 100, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] + assert [ + "2020-06-13T13:00:00+00:00", + "2020-06-13T14:00:00+00:00", + ] == response_dict["datetime"] + assert 100 == response_dict["limit"] + + +def test_collection_search_extension_models(): + """Test /collections endpoint with collection-search ext with additional models.""" + collections_get_request_model = create_request_model( + model_name="SearchGetRequest", + base_model=CollectionSearchExtensionGetRequest, + mixins=[ + FreeTextExtensionGetRequest, + FilterExtensionGetRequest, + QueryExtensionGetRequest, + SortExtensionGetRequest, + FieldsExtensionGetRequest, + ], + request_type="GET", + ) + + api = StacApi( + settings=ApiSettings(), + client=DummyCoreClient(), + extensions=[ + CollectionSearchExtension( + conformance_classes=[ + ConformanceClasses.COLLECTIONSEARCH, + ConformanceClasses.BASIS, + ConformanceClasses.FREETEXT, + ConformanceClasses.FILTER, + ConformanceClasses.QUERY, + ConformanceClasses.SORT, + ConformanceClasses.FIELDS, + ] + ) + ], + collections_get_request_model=collections_get_request_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 "https://api.stacspec.org/v1.0.0-rc.1/collection-search" in conforms + assert ( + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + in conforms + ) + assert ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" in conforms + ) + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields" in conforms + + response = client.get("/collections") + assert response.is_success, response.json() + response_dict = response.json() + assert "bbox" in response_dict + assert "datetime" in response_dict + assert "limit" in response_dict + assert "q" in response_dict + assert "filter" in response_dict + assert "query" in response_dict + assert "sortby" in response_dict + assert "fields" in response_dict + + response = client.get( + "/collections", + params={ + "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z", + "bbox": "-175.05,-85.05,175.05,85.05", + "limit": 100, + "q": "EO,Earth Observation", + "filter": "id='item_id' AND collection='collection_id'", + "query": quote_plus( + json.dumps({"eo:cloud_cover": {"gte": 95}}), + ), + "sortby": "-gsd,-datetime", + "fields": "properties.datetime", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] + assert [ + "2020-06-13T13:00:00+00:00", + "2020-06-13T14:00:00+00:00", + ] == response_dict["datetime"] + assert 100 == response_dict["limit"] + assert ["EO", "Earth Observation"] == response_dict["q"] + assert "id='item_id' AND collection='collection_id'" == response_dict["filter"] + assert "filter_crs" in response_dict + assert "cql2-text" in response_dict["filter_lang"] + assert "query" in response_dict + assert ["-gsd", "-datetime"] == response_dict["sortby"] + assert ["properties.datetime"] == response_dict["fields"] From 69dcee0f23d4a20db2d8e30a9300c0ec35b35b9b Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 24 Jul 2024 09:25:13 +0200 Subject: [PATCH 18/21] use same Limit object for collection_items and get_search request model (#738) * use same Limit object for collection_items and get_search request model * add tests --- CHANGES.md | 4 ++- stac_fastapi/api/stac_fastapi/api/models.py | 8 ++++- .../types/stac_fastapi/types/search.py | 6 ++-- stac_fastapi/types/tests/test_limit.py | 35 ++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index fbc15e50..7f5d6ba2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,9 +4,11 @@ ### Changed -* add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) +* Add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) +* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#737](https://github.com/stac-utils/stac-fastapi/pull/737)) ### Added + * Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) * Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736)) diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 5a239b9f..d2e06abc 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -14,6 +14,7 @@ APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, + Limit, _bbox_converter, _datetime_converter, ) @@ -113,7 +114,12 @@ class ItemCollectionUri(APIRequest): """Get item collection.""" collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() - limit: Annotated[int, Query()] = attr.ib(default=10) + limit: Annotated[ + Optional[Limit], + Query( + description="Limits the number of results that are included in each page of the response (capped to 10_000)." # noqa: E501 + ), + ] = attr.ib(default=10) bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) datetime: Optional[DateTimeType] = attr.ib( default=None, converter=_datetime_converter diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 064ae10c..cfa8baf9 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -174,9 +174,9 @@ class BaseSearchGetRequest(APIRequest): default=None, converter=_datetime_converter ) limit: Annotated[ - Optional[int], + Optional[Limit], Query( - description="Limits the number of results that are included in each page of the response." # noqa: E501 + description="Limits the number of results that are included in each page of the response (capped to 10_000)." # noqa: E501 ), ] = attr.ib(default=10) @@ -186,5 +186,5 @@ class BaseSearchPostRequest(Search): limit: Optional[Limit] = Field( 10, - description="Limits the number of results that are included in each page of the response.", # noqa: E501 + description="Limits the number of results that are included in each page of the response (capped to 10_000).", # noqa: E501 ) diff --git a/stac_fastapi/types/tests/test_limit.py b/stac_fastapi/types/tests/test_limit.py index e5b2125b..d4c03f33 100644 --- a/stac_fastapi/types/tests/test_limit.py +++ b/stac_fastapi/types/tests/test_limit.py @@ -1,7 +1,9 @@ import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient from pydantic import ValidationError -from stac_fastapi.types.search import BaseSearchPostRequest +from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest @pytest.mark.parametrize("value", [0, -1]) @@ -20,3 +22,34 @@ def test_limit(value): def test_limit_le(value): search = BaseSearchPostRequest(limit=value) assert search.limit == 10_000 + + +def test_limit_get_request(): + """test GET model.""" + + app = FastAPI() + + @app.get("/test") + def route(model=Depends(BaseSearchGetRequest)): + return model + + with TestClient(app) as client: + resp = client.get( + "/test", + params={ + "limit": 10, + }, + ) + assert resp.status_code == 200 + response_dict = resp.json() + assert response_dict["limit"] == 10 + + resp = client.get( + "/test", + params={ + "limit": 100_000, + }, + ) + assert resp.status_code == 200 + response_dict = resp.json() + assert response_dict["limit"] == 10_000 From 4adcf0e523b83562d2807a0e500d5d8484aa6cce Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 25 Jul 2024 22:48:10 +0200 Subject: [PATCH 19/21] add POST - /collections collection-search (#739) * add POST - /collections collection-search * fix --- CHANGES.md | 4 +- .../stac_fastapi/extensions/core/__init__.py | 3 +- .../core/collection_search/__init__.py | 12 +- .../core/collection_search/client.py | 49 ++++ .../collection_search/collection_search.py | 76 +++++- .../core/collection_search/request.py | 120 ++++++++- .../tests/test_collection_search.py | 237 +++++++++++++++++- 7 files changed, 476 insertions(+), 25 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/client.py diff --git a/CHANGES.md b/CHANGES.md index 7f5d6ba2..0fcc0713 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,12 +5,12 @@ ### Changed * Add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) -* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#737](https://github.com/stac-utils/stac-fastapi/pull/737)) +* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#738](https://github.com/stac-utils/stac-fastapi/pull/738)) ### Added * Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) -* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736)) +* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736), [#739](https://github.com/stac-utils/stac-fastapi/pull/739)) ## [3.0.0b2] - 2024-07-09 diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 385bd902..17eccde7 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,7 +1,7 @@ """stac_api.extensions.core module.""" from .aggregation import AggregationExtension -from .collection_search import CollectionSearchExtension +from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension @@ -24,4 +24,5 @@ "TokenPaginationExtension", "TransactionExtension", "CollectionSearchExtension", + "CollectionSearchPostExtension", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py index f919491d..eed6d502 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py @@ -1,5 +1,13 @@ """Collection-Search extension module.""" -from .collection_search import CollectionSearchExtension, ConformanceClasses +from .collection_search import ( + CollectionSearchExtension, + CollectionSearchPostExtension, + ConformanceClasses, +) -__all__ = ["CollectionSearchExtension", "ConformanceClasses"] +__all__ = [ + "CollectionSearchExtension", + "CollectionSearchPostExtension", + "ConformanceClasses", +] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/client.py new file mode 100644 index 00000000..ac148dfb --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/client.py @@ -0,0 +1,49 @@ +"""collection-search extensions clients.""" + +import abc + +import attr + +from stac_fastapi.types import stac + +from .request import BaseCollectionSearchPostRequest + + +@attr.s +class AsyncBaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC collection-search POST extension.""" + + @abc.abstractmethod + async def post_all_collections( + self, + search_request: BaseCollectionSearchPostRequest, + **kwargs, + ) -> stac.ItemCollection: + """Get all available collections. + + Called with `POST /collections`. + + Returns: + A list of collections. + + """ + ... + + +@attr.s +class BaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC collection-search POST extension.""" + + @abc.abstractmethod + def post_all_collections( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + """Get all available collections. + + Called with `POST /collections`. + + Returns: + A list of collections. + + """ + ... diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py index aac81205..2927cd82 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py @@ -1,14 +1,20 @@ """Collection-Search extension.""" from enum import Enum -from typing import List, Optional +from typing import List, Optional, Union import attr -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from stac_pydantic.api.collections import Collections +from stac_pydantic.shared import MimeTypes +from stac_fastapi.api.models import GeoJSONResponse +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.extension import ApiExtension -from .request import CollectionSearchExtensionGetRequest +from .client import AsyncBaseCollectionSearchClient, BaseCollectionSearchClient +from .request import BaseCollectionSearchGetRequest, BaseCollectionSearchPostRequest class ConformanceClasses(str, Enum): @@ -46,7 +52,7 @@ class CollectionSearchExtension(ApiExtension): the extension """ - GET = CollectionSearchExtensionGetRequest + GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest) POST = None conformance_classes: List[str] = attr.ib( @@ -64,3 +70,65 @@ def register(self, app: FastAPI) -> None: None """ pass + + +@attr.s +class CollectionSearchPostExtension(CollectionSearchExtension): + """Collection-Search Extension. + + Extents the collection-search extension with an additional + POST - /collections endpoint + + NOTE: the POST - /collections endpoint can be conflicting with the + POST /collections endpoint registered for the Transaction extension. + + https://github.com/stac-api-extensions/collection-search + + Attributes: + conformance_classes (list): Defines the list of conformance classes for + the extension + """ + + client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib() + settings: ApiSettings = attr.ib() + conformance_classes: List[str] = attr.ib( + default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS] + ) + schema_href: Optional[str] = attr.ib(default=None) + router: APIRouter = attr.ib(factory=APIRouter) + + GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest) + POST: BaseCollectionSearchPostRequest = attr.ib( + default=BaseCollectionSearchPostRequest + ) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + self.router.prefix = app.state.router_prefix + + self.router.add_api_route( + name="Collections", + path="/collections", + methods=["POST"], + response_model=( + Collections if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collections, + }, + }, + response_class=GeoJSONResponse, + endpoint=create_async_endpoint(self.client.post_all_collections, self.POST), + ) + app.include_router(self.router) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py index 663f488d..0bc6d22e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -1,18 +1,26 @@ """Request models for the Collection-Search extension.""" -from typing import Optional +from datetime import datetime as dt +from typing import List, Optional, Tuple, cast import attr from fastapi import Query +from pydantic import BaseModel, Field, field_validator +from stac_pydantic.api.search import SearchDatetime from stac_pydantic.shared import BBox from typing_extensions import Annotated from stac_fastapi.types.rfc3339 import DateTimeType -from stac_fastapi.types.search import APIRequest, _bbox_converter, _datetime_converter +from stac_fastapi.types.search import ( + APIRequest, + Limit, + _bbox_converter, + _datetime_converter, +) @attr.s -class CollectionSearchExtensionGetRequest(APIRequest): +class BaseCollectionSearchGetRequest(APIRequest): """Basics additional Collection-Search parameters for the GET request.""" bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) @@ -20,8 +28,112 @@ class CollectionSearchExtensionGetRequest(APIRequest): default=None, converter=_datetime_converter ) limit: Annotated[ - Optional[int], + Optional[Limit], Query( description="Limits the number of results that are included in each page of the response." # noqa: E501 ), ] = attr.ib(default=10) + + +class BaseCollectionSearchPostRequest(BaseModel): + """Collection-Search POST model.""" + + bbox: Optional[BBox] = None + datetime: Optional[str] = None + limit: Optional[Limit] = Field( + 10, + description="Limits the number of results that are included in each page of the response (capped to 10_000).", # noqa: E501 + ) + + # Private properties to store the parsed datetime values. + # Not part of the model schema. + _start_date: Optional[dt] = None + _end_date: Optional[dt] = None + + # Properties to return the private values + @property + def start_date(self) -> Optional[dt]: + """start date.""" + return self._start_date + + @property + def end_date(self) -> Optional[dt]: + """end date.""" + return self._end_date + + @field_validator("bbox") + @classmethod + def validate_bbox(cls, v: BBox) -> BBox: + """validate bbox.""" + if v: + # Validate order + if len(v) == 4: + xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v) + else: + xmin, ymin, min_elev, xmax, ymax, max_elev = cast( + Tuple[int, int, int, int, int, int], v + ) + if max_elev < min_elev: + raise ValueError( + "Maximum elevation must greater than minimum elevation" + ) + + if xmax < xmin: + raise ValueError( + "Maximum longitude must be greater than minimum longitude" + ) + + if ymax < ymin: + raise ValueError( + "Maximum longitude must be greater than minimum longitude" + ) + + # Validate against WGS84 + if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90: + raise ValueError("Bounding box must be within (-180, -90, 180, 90)") + + return v + + @field_validator("datetime") + @classmethod + def validate_datetime(cls, value: str) -> str: + """validate datetime.""" + # Split on "/" and replace no value or ".." with None + values = [v if v and v != ".." else None for v in value.split("/")] + + # If there are more than 2 dates, it's invalid + if len(values) > 2: + raise ValueError( + """Invalid datetime range. Too many values. + Must match format: {begin_date}/{end_date}""" + ) + + # If there is only one date, duplicate to use for both start and end dates + if len(values) == 1: + values = [values[0], values[0]] + + # Cast because pylance gets confused by the type adapter and annotated type + dates = cast( + List[Optional[dt]], + [ + # Use the type adapter to validate the datetime strings, + # strict is necessary due to pydantic issues #8736 and #8762 + SearchDatetime.validate_strings(v, strict=True) if v else None + for v in values + ], + ) + + # If there is a start and end date, + # check that the start date is before the end date + if dates[0] and dates[1] and dates[0] > dates[1]: + raise ValueError( + "Invalid datetime range. Begin date after end date. " + "Must match format: {begin_date}/{end_date}" + ) + + # Store the parsed dates + cls._start_date = dates[0] + cls._end_date = dates[1] + + # Return the original string value + return value diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py index 856c5b03..edc29221 100644 --- a/stac_fastapi/extensions/tests/test_collection_search.py +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -1,20 +1,44 @@ import json from urllib.parse import quote_plus +import attr from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import create_request_model -from stac_fastapi.extensions.core import CollectionSearchExtension +from stac_fastapi.extensions.core import ( + CollectionSearchExtension, + CollectionSearchPostExtension, +) from stac_fastapi.extensions.core.collection_search import ConformanceClasses +from stac_fastapi.extensions.core.collection_search.client import ( + BaseCollectionSearchClient, +) from stac_fastapi.extensions.core.collection_search.request import ( - CollectionSearchExtensionGetRequest, + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) +from stac_fastapi.extensions.core.fields.request import ( + FieldsExtensionGetRequest, + FieldsExtensionPostRequest, +) +from stac_fastapi.extensions.core.filter.request import ( + FilterExtensionGetRequest, + FilterExtensionPostRequest, +) +from stac_fastapi.extensions.core.free_text.request import ( + FreeTextExtensionGetRequest, + FreeTextExtensionPostRequest, +) +from stac_fastapi.extensions.core.query.request import ( + QueryExtensionGetRequest, + QueryExtensionPostRequest, +) +from stac_fastapi.extensions.core.sort.request import ( + SortExtensionGetRequest, + SortExtensionPostRequest, ) -from stac_fastapi.extensions.core.fields.request import FieldsExtensionGetRequest -from stac_fastapi.extensions.core.filter.request import FilterExtensionGetRequest -from stac_fastapi.extensions.core.free_text.request import FreeTextExtensionGetRequest -from stac_fastapi.extensions.core.query.request import QueryExtensionGetRequest -from stac_fastapi.extensions.core.sort.request import SortExtensionGetRequest +from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient @@ -40,13 +64,22 @@ def item_collection(self, *args, **kwargs): raise NotImplementedError +@attr.s +class DummyPostClient(BaseCollectionSearchClient): + def post_all_collections( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + """fake method.""" + return search_request.model_dump() + + def test_collection_search_extension_default(): - """Test /collections endpoint with collection-search ext.""" + """Test GET - /collections endpoint with collection-search ext.""" api = StacApi( settings=ApiSettings(), client=DummyCoreClient(), extensions=[CollectionSearchExtension()], - collections_get_request_model=CollectionSearchExtensionGetRequest, + collections_get_request_model=BaseCollectionSearchGetRequest, ) with TestClient(api.app) as client: response = client.get("/conformance") @@ -87,10 +120,12 @@ def test_collection_search_extension_default(): def test_collection_search_extension_models(): - """Test /collections endpoint with collection-search ext with additional models.""" + """Test GET - /collections endpoint with collection-search ext + with additional models. + """ collections_get_request_model = create_request_model( model_name="SearchGetRequest", - base_model=CollectionSearchExtensionGetRequest, + base_model=BaseCollectionSearchGetRequest, mixins=[ FreeTextExtensionGetRequest, FilterExtensionGetRequest, @@ -106,6 +141,7 @@ def test_collection_search_extension_models(): client=DummyCoreClient(), extensions=[ CollectionSearchExtension( + GET=collections_get_request_model, conformance_classes=[ ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS, @@ -114,7 +150,7 @@ def test_collection_search_extension_models(): ConformanceClasses.QUERY, ConformanceClasses.SORT, ConformanceClasses.FIELDS, - ] + ], ) ], collections_get_request_model=collections_get_request_model, @@ -179,3 +215,180 @@ def test_collection_search_extension_models(): assert "query" in response_dict assert ["-gsd", "-datetime"] == response_dict["sortby"] assert ["properties.datetime"] == response_dict["fields"] + + +def test_collection_search_extension_post_default(): + """Test POST - /collections endpoint with collection-search ext.""" + settings = ApiSettings() + collection_search_ext = CollectionSearchPostExtension( + client=DummyPostClient(), + settings=settings, + ) + + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=[collection_search_ext], + ) + with TestClient(api.app) as client: + response = client.get("/conformance") + assert response.is_success, response.json() + response_dict = response.json() + assert ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search" + in response_dict["conformsTo"] + ) + assert ( + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + in response_dict["conformsTo"] + ) + + response = client.post("/collections", json={}) + assert response.is_success, response.json() + response_dict = response.json() + assert "bbox" in response_dict + assert "datetime" in response_dict + assert "limit" in response_dict + assert response_dict["limit"] == 10 + + response = client.post( + "/collections", + json={ + "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z", + "bbox": [-175.05, -85.05, 175.05, 85.05], + "limit": 100_000, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] + assert "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z" == response_dict["datetime"] + assert 10_000 == response_dict["limit"] + + +def test_collection_search_extension_post_models(): + """Test POST - /collections endpoint with collection-search ext + with additional models. + """ + post_request_model = create_request_model( + model_name="SearchPostRequest", + base_model=BaseCollectionSearchPostRequest, + mixins=[ + FreeTextExtensionPostRequest, + FilterExtensionPostRequest, + QueryExtensionPostRequest, + SortExtensionPostRequest, + FieldsExtensionPostRequest, + ], + request_type="POST", + ) + + get_request_model = create_request_model( + model_name="SearchGetRequest", + base_model=BaseCollectionSearchGetRequest, + mixins=[ + FreeTextExtensionGetRequest, + FilterExtensionGetRequest, + QueryExtensionGetRequest, + SortExtensionGetRequest, + FieldsExtensionGetRequest, + ], + request_type="GET", + ) + + settings = ApiSettings() + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=[ + CollectionSearchPostExtension( + settings=settings, + client=DummyPostClient(), + GET=get_request_model, + POST=post_request_model, + conformance_classes=[ + ConformanceClasses.COLLECTIONSEARCH, + ConformanceClasses.BASIS, + ConformanceClasses.FREETEXT, + ConformanceClasses.FILTER, + ConformanceClasses.QUERY, + ConformanceClasses.SORT, + ConformanceClasses.FIELDS, + ], + ) + ], + collections_get_request_model=get_request_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 "https://api.stacspec.org/v1.0.0-rc.1/collection-search" in conforms + assert ( + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + in conforms + ) + assert ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" in conforms + ) + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#query" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort" in conforms + assert "https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields" in conforms + + response = client.post("/collections", json={}) + assert response.is_success, response.json() + response_dict = response.json() + assert "bbox" in response_dict + assert "datetime" in response_dict + assert "limit" in response_dict + assert "q" in response_dict + assert "filter" in response_dict + assert "query" in response_dict + assert "sortby" in response_dict + assert "fields" in response_dict + + response = client.post( + "/collections", + json={ + "datetime": "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z", + "bbox": [-175.05, -85.05, 175.05, 85.05], + "limit": 100_000, + "q": ["EO", "Earth Observation"], + "filter": { + "op": "and", + "args": [ + {"op": "=", "args": [{"property": "id"}, "item_id"]}, + { + "op": "=", + "args": [{"property": "collection"}, "collection_id"], + }, + ], + }, + "query": {"eo:cloud_cover": {"gte": 95}}, + "sortby": [ + { + "field": "properties.gsd", + "direction": "desc", + }, + { + "field": "properties.datetime", + "direction": "asc", + }, + ], + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] + assert "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z" == response_dict["datetime"] + assert 10_000 == response_dict["limit"] + assert ["EO", "Earth Observation"] == response_dict["q"] + assert response_dict["filter"] + assert "filter_crs" in response_dict + assert "cql2-json" in response_dict["filter_lang"] + assert response_dict["query"] + assert response_dict["sortby"] + assert response_dict["fields"] From e376e3000c0231d3bbf8e12a8f7a9a254067b3c9 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 26 Jul 2024 09:06:04 +0200 Subject: [PATCH 20/21] Release/v3.0.0b3 (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0b2 → 3.0.0b3 --- CHANGES.md | 7 +++++-- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0fcc0713..c99d49b2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,8 @@ # Changelog -## [Unreleased] - TBD +## [Unreleased] + +## [3.0.0b3] - 2024-07-25 ### Changed @@ -448,7 +450,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0b3]: [3.0.0b2]: [3.0.0b1]: [3.0.0a4]: diff --git a/VERSION b/VERSION index 2aa4d8f0..005e92c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0b2 +3.0.0b3 diff --git a/pyproject.toml b/pyproject.toml index 9f417299..8c1943d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0b2" +current_version = "3.0.0b3" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 7296e8a9..c09ebf00 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b2" +__version__ = "3.0.0b3" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 7296e8a9..c09ebf00 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b2" +__version__ = "3.0.0b3" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 7296e8a9..c09ebf00 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b2" +__version__ = "3.0.0b3" From 4979a89b4dc1534645388f182046955986fee669 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 1 Aug 2024 11:41:10 +0200 Subject: [PATCH 21/21] 3.0 release (#743) * prepare 3.0 * update changelog * add 3.0.0 link to changelog * purge pre-releases from comparisons * add changelog in migration guide * fix changelog --------- Co-authored-by: jonhealy1 --- CHANGES.md | 63 +++++++++++-------- VERSION | 2 +- docs/src/migrations/v3.0.0.md | 54 ++++++++++++++++ pyproject.toml | 2 +- stac_fastapi/api/setup.py | 2 +- stac_fastapi/api/stac_fastapi/api/config.py | 1 - stac_fastapi/api/stac_fastapi/api/openapi.py | 50 --------------- stac_fastapi/api/stac_fastapi/api/routes.py | 8 --- stac_fastapi/api/stac_fastapi/api/version.py | 3 +- stac_fastapi/extensions/setup.py | 4 +- .../stac_fastapi/extensions/core/__init__.py | 2 - .../stac_fastapi/extensions/core/context.py | 46 -------------- .../extensions/core/fields/request.py | 26 -------- .../stac_fastapi/extensions/version.py | 3 +- stac_fastapi/types/stac_fastapi/types/core.py | 17 ----- .../types/stac_fastapi/types/version.py | 3 +- 16 files changed, 102 insertions(+), 184 deletions(-) delete mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/context.py diff --git a/CHANGES.md b/CHANGES.md index c99d49b2..992d23c3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,11 +2,29 @@ ## [Unreleased] -## [3.0.0b3] - 2024-07-25 +## [3.0.0] - 2024-07-29 + +Full changelog: https://stac-utils.github.io/stac-fastapi/migrations/v3.0.0/#changelog + +**Changes since 3.0.0b3:** + +### Changed + +* Add version pinning (`~=3.0`) for stac-fastapi submodules +* Moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule + +### Removed + +* Removed the `Context` extension +* Removed deprecated `stac_fastapi.api.openapi.config_openapi` method and `stac_fastapi.api.openapi.VndOaiResponse` class +* Removed `response_class` argument in `stac_fastapi.api.routes.create_async_endpoint` method +* Removed `filter_fields` property in `stac_fastapi.extensions.core.fields.request.PostFieldsExtension` class + +## 3.0.0b3 - 2024-07-25 ### Changed -* Add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) +* Add more openapi metadata in input models ([#734](https://github.com/stac-utils/stac-fastapi/pull/734)) * Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#738](https://github.com/stac-utils/stac-fastapi/pull/738)) ### Added @@ -14,13 +32,13 @@ * Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) * Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736), [#739](https://github.com/stac-utils/stac-fastapi/pull/739)) -## [3.0.0b2] - 2024-07-09 +## 3.0.0b2 - 2024-07-09 ### Changed -* move back to `@attrs` (instead of dataclass) for `APIRequest` (model for GET request) class type [#729](https://github.com/stac-utils/stac-fastapi/pull/729) +* move back to `@attrs` (instead of dataclass) for `APIRequest` (model for GET request) class type ([#729](https://github.com/stac-utils/stac-fastapi/pull/729)) -## [3.0.0b1] - 2024-07-05 +## 3.0.0b1 - 2024-07-05 ### Added @@ -32,33 +50,33 @@ ### Removed -* Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) +* Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` ([#716](https://github.com/stac-utils/stac-fastapi/pull/716)) * Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` * Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) * Removed use of `FieldsExtension` in `stac_fastapi.api.app.StacApi`. If users use `FieldsExtension`, they would have to handle overpassing the model validation step by returning a `JSONResponse` from the `post_search` and `get_search` client methods. ### Changed -* Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714) -* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) +* Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type ([#714](https://github.com/stac-utils/stac-fastapi/pull/714)) +* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule ([#717](https://github.com/stac-utils/stac-fastapi/pull/717)) * update FastAPI requirement to `>=0.111.0` -## [3.0.0a4] - 2024-06-27 +## 3.0.0a4 - 2024-06-27 ### Fixed -* Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) +* Updated default filter language in filter extension's POST search request model to match the extension's documentation ([#711](https://github.com/stac-utils/stac-fastapi/issues/711)) ### Removed -* Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) -* Removed `add_middleware` method in `StacApi` object and let starlette handle the middleware stack creation [721](https://github.com/stac-utils/stac-fastapi/pull/721) +* Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` ([#716](https://github.com/stac-utils/stac-fastapi/pull/716)) +* Removed `add_middleware` method in `StacApi` object and let starlette handle the middleware stack creation ([#721](https://github.com/stac-utils/stac-fastapi/pull/721)) -## [3.0.0a3] - 2024-06-13 +## 3.0.0a3 - 2024-06-13 ### Added -* Add base support for the Aggregation extension [#684](https://github.com/stac-utils/stac-fastapi/pull/684) +* Add base support for the Aggregation extension ([#684](https://github.com/stac-utils/stac-fastapi/pull/684)) ### Changed @@ -67,13 +85,13 @@ * Removed `default_includes` from `stac_fastapi.types.config.ApiSettings` ([#706](https://github.com/stac-utils/stac-fastapi/pull/706)) * Deprecated *Fields* extension `PostFieldsExtension.filter_fields` property ([#706](https://github.com/stac-utils/stac-fastapi/pull/706)) -## [3.0.0a2] - 2024-05-31 +## 3.0.0a2 - 2024-05-31 ### Fixed * Fix missing default (`None`) for optional `query` attribute in `QueryExtensionPostRequest` model ([#701](https://github.com/stac-utils/stac-fastapi/pull/701)) -## [3.0.0a1] - 2024-05-22 +## 3.0.0a1 - 2024-05-22 ### Changed @@ -89,7 +107,7 @@ * Make `str_to_interval` not return a tuple for single-value input (fixing `datetime` argument as passed to `get_search`). ([#692](https://github.com/stac-utils/stac-fastapi/pull/692)) -## [3.0.0a0] - 2024-05-06 +## 3.0.0a0 - 2024-05-06 ### Added @@ -450,15 +468,8 @@ * First PyPi release! -[Unreleased]: -[3.0.0b3]: -[3.0.0b2]: -[3.0.0b1]: -[3.0.0a4]: -[3.0.0a3]: -[3.0.0a2]: -[3.0.0a1]: -[3.0.0a0]: +[Unreleased]: +[3.0.0]: [2.5.5.post1]: [2.5.5]: [2.5.4]: diff --git a/VERSION b/VERSION index 005e92c1..4a36342f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0b3 +3.0.0 diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index f781687c..bc0b03b7 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -3,6 +3,60 @@ This document aims to help you update your application from **stac-fastapi** 2.5 to 3.0.0. +## CHANGELOG + +### Removed + +* Removed the `Context` extension +* Removed `stac_fastapi.api.openapi.config_openapi` method and `stac_fastapi.api.openapi.VndOaiResponse` class +* Removed `response_class` argument in `stac_fastapi.api.routes.create_async_endpoint` method +* Removed `filter_fields` property in `stac_fastapi.extensions.core.fields.request.PostFieldsExtension` class +* Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` +* Removed `default_includes` from `stac_fastapi.types.config.ApiSettings` ([#706](https://github.com/stac-utils/stac-fastapi/pull/706)) +* Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) +* Removed use of `FieldsExtension` in `stac_fastapi.api.app.StacApi`. NOTE: If users use `FieldsExtension`, they HAVE TO handle skipping the model validation step by returning a `JSONResponse` from the `post_search` and `get_search` client methods +* Removed `add_middleware` method in `StacApi` object and let starlette handle the middleware stack creation ([#721](https://github.com/stac-utils/stac-fastapi/pull/721)) +* Removed `pystac` dependecy, as it was just used for a *datetime-to-string* function ([#690](https://github.com/stac-utils/stac-fastapi/pull/690)) +* Removed internal Search and Operator Types in favor of stac_pydantic Types ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) + +### Changed + +* Update to **pydantic 2.0** ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Update stac-pydantic requirement to `~3.1` ([#697](https://github.com/stac-utils/stac-fastapi/pull/697)) +* Switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) +* Update FastAPI requirement to `>=0.111.0` +* Moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule +* Add more openapi metadata in input models ([#734](https://github.com/stac-utils/stac-fastapi/pull/734)) +* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#738](https://github.com/stac-utils/stac-fastapi/pull/738)) +* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule ([#717](https://github.com/stac-utils/stac-fastapi/pull/717)) +* Added option for default route dependencies `*` can be used for `path` or `method` to match all allowed route. ([#705](https://github.com/stac-utils/stac-fastapi/pull/705)) +* Moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704)) +* Replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) +* Fix response model *validation* ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Use status code 201 for Item/Collection creation ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Add `response_class` attribute in `FilterExtension` class +* Add version pinning (`~=3.0`) for stac-fastapi submodules + +### Fixed + +* Updated default `filter` language in filter extension's POST search request model to match the extension's documentation ([#711](https://github.com/stac-utils/stac-fastapi/issues/711)) +* Fix missing default (`None`) for optional `query` attribute in `QueryExtensionPostRequest` model ([#701](https://github.com/stac-utils/stac-fastapi/pull/701)) +* Make `str_to_interval` not return a tuple for single-value input (fixing `datetime` argument as passed to `get_search`). ([#692](https://github.com/stac-utils/stac-fastapi/pull/692)) + +### Added + +* Add enhanced middleware configuration to the StacApi class, enabling specific middleware options and dynamic addition post-application initialization. ([#442](https://github.com/stac-utils/stac-fastapi/pull/442)) +* Add *response* pydantic models to OpenAPI, even if model validation is turned off ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for: + - `/collections`: **collections_get_request_model**, default to `EmptyRequest` + - `/collections/{collection_id}`: **collection_get_request_model**, default to `CollectionUri` + - `/collections/{collection_id}/items`: **items_get_request_model**, default to `ItemCollectionUri` + - `/collections/{collection_id}/items/{item_id}`: **item_get_request_model**, default to `ItemUri` +* Add **Aggregation** extension ([#684](https://github.com/stac-utils/stac-fastapi/pull/684)) +* Add **Free-text** extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655)) +* Add **Collection-Search** extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736), [#739](https://github.com/stac-utils/stac-fastapi/pull/739)) + + ## Dependencies - **pydantic~=2.0** diff --git a/pyproject.toml b/pyproject.toml index 8c1943d6..4786a7dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0b3" +current_version = "3.0.0" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 5050d3a7..7f4b541f 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -7,7 +7,7 @@ install_requires = [ "brotli_asgi", - "stac-fastapi.types", + "stac-fastapi.types~=3.0", ] extra_reqs = { diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 275159a0..90ed60a4 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -12,7 +12,6 @@ class ApiExtensions(enum.Enum): Ref: https://github.com/stac-api-extensions """ - context = "context" fields = "fields" filter = "filter" query = "query" diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index ab90ce42..5aad3523 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -1,30 +1,10 @@ """openapi.""" -import warnings - from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route, request_response -from stac_fastapi.api.config import ApiExtensions -from stac_fastapi.types.config import ApiSettings - - -class VndOaiResponse(JSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/vnd.oai.openapi+json;version=3.0" - - def __init__(self, *args, **kwargs): - """Init function with deprecation warning.""" - warnings.warn( - "VndOaiResponse is deprecated and will be removed in v3.0", - DeprecationWarning, - ) - super().__init__(*args, **kwargs) - def update_openapi(app: FastAPI) -> FastAPI: """Update OpenAPI response content-type. @@ -55,33 +35,3 @@ async def patched_openapi_endpoint(req: Request) -> Response: # return the patched app return app - - -def config_openapi(app: FastAPI, settings: ApiSettings): - """Config openapi.""" - warnings.warn( - "config_openapi is deprecated and will be removed in v3.0", - DeprecationWarning, - ) - - def custom_openapi(): - """Config openapi.""" - if app.openapi_schema: - return app.openapi_schema - - openapi_schema = get_openapi( - title="Arturo STAC API", version="0.1", routes=app.routes - ) - - if settings.api_extension_is_enabled(ApiExtensions.fields): - openapi_schema["paths"]["/search"]["get"]["responses"]["200"]["content"][ - "application/json" - ]["schema"] = {"$ref": "#/components/schemas/ItemCollection"} - openapi_schema["paths"]["/search"]["post"]["responses"]["200"]["content"][ - "application/json" - ]["schema"] = {"$ref": "#/components/schemas/ItemCollection"} - - app.openapi_schema = openapi_schema - return app.openapi_schema - - app.openapi = custom_openapi diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index bd6f4d9c..c159facc 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -3,7 +3,6 @@ import copy import functools import inspect -import warnings from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union from fastapi import Depends, params @@ -38,19 +37,12 @@ async def run(*args, **kwargs): def create_async_endpoint( func: Callable, request_model: Union[Type[APIRequest], Type[BaseModel], Dict], - response_class: Optional[Type[Response]] = None, ): """Wrap a function in a coroutine which may be used to create a FastAPI endpoint. Synchronous functions are executed asynchronously using a background thread. """ - if response_class: - warnings.warn( - "`response_class` option is deprecated, please set the Response class directly in the endpoint.", # noqa: E501 - DeprecationWarning, - ) - if not inspect.iscoroutinefunction(func): func = sync_to_async(func) diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index c09ebf00..5996faa4 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,3 @@ """Library version.""" -__version__ = "3.0.0b3" + +__version__ = "3.0.0" diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index 39bc59b3..cf49b5d0 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -7,8 +7,8 @@ desc = f.read() install_requires = [ - "stac-fastapi.types", - "stac-fastapi.api", + "stac-fastapi.types~=3.0", + "stac-fastapi.api~=3.0", ] extra_reqs = { diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 17eccde7..7fb122e8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -2,7 +2,6 @@ from .aggregation import AggregationExtension from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension -from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension from .free_text import FreeTextAdvancedExtension, FreeTextExtension @@ -13,7 +12,6 @@ __all__ = ( "AggregationExtension", - "ContextExtension", "FieldsExtension", "FilterExtension", "FreeTextExtension", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py deleted file mode 100644 index 4037ba93..00000000 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Context extension.""" - -import warnings -from typing import List, Optional - -import attr -from fastapi import FastAPI - -from stac_fastapi.types.extension import ApiExtension - - -@attr.s -class ContextExtension(ApiExtension): - """Context Extension. - - The Context extension adds a JSON object to ItemCollection responses (`/search`, - `/collections/{collection_id}/items`) which includes the number of items matched, - returned, and the limit requested. - https://github.com/stac-api-extensions/context - """ - - conformance_classes: List[str] = attr.ib( - factory=lambda: ["https://api.stacspec.org/v1.0.0-rc.2/item-search#context"] - ) - schema_href: Optional[str] = attr.ib( - default="https://raw.githubusercontent.com/stac-api-extensions/context/v1.0.0-rc.2/json-schema/schema.json" - ) - - def __attrs_post_init__(self): - """init.""" - warnings.warn( - "The ContextExtension is deprecated and will be removed in 3.0.", - DeprecationWarning, - stacklevel=1, - ) - - 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/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index d3737ea4..02d3dd19 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,6 +1,5 @@ """Request models for the fields extension.""" -import warnings from typing import Dict, List, Optional, Set import attr @@ -44,31 +43,6 @@ def _get_field_dict(fields: Optional[Set[str]]) -> Dict: return field_dict - @property - def filter_fields(self) -> Dict: - """Create pydantic include/exclude expression. - - Create dictionary of fields to include/exclude on model export based on - the included and excluded fields passed to the API - Ref: https://pydantic-docs.helpmanual.io/usage/exporting_models/#advanced-include-and-exclude - """ - warnings.warn( - """The `PostFieldsExtension.filter_fields` - method is deprecated and will be removed in 3.0.""", - DeprecationWarning, - stacklevel=1, - ) - - # Always include default_includes, even if they - # exist in the exclude list. - include = (self.include or set()) - (self.exclude or set()) - include |= set() - - return { - "include": self._get_field_dict(include), - "exclude": self._get_field_dict(self.exclude), - } - def _fields_converter( val: Annotated[ diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index c09ebf00..5996faa4 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,3 @@ """Library version.""" -__version__ = "3.0.0b3" + +__version__ = "3.0.0" diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 003a765e..18381b7c 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,8 +1,6 @@ """Base clients.""" import abc -import importlib -import warnings from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin @@ -784,18 +782,3 @@ async def item_collection( An ItemCollection. """ ... - - -# TODO: remove for 3.0.0 final release -def __getattr__(name: str) -> Any: - if name in ["AsyncBaseFiltersClient", "BaseFiltersClient"]: - warnings.warn( - f"""importing {name} from `stac_fastapi.types.core` is deprecated, - please import it from `stac_fastapi.extensions.core.filter.client`.""", - DeprecationWarning, - stacklevel=2, - ) - clients = importlib.import_module("stac_fastapi.extensions.core.filter.client") - return getattr(clients, name) - - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index c09ebf00..5996faa4 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,3 @@ """Library version.""" -__version__ = "3.0.0b3" + +__version__ = "3.0.0"