diff --git a/CHANGES.md b/CHANGES.md index 49920eb90..992d23c37 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,12 +1,82 @@ # Changelog -## [Unreleased] - TBD +## [Unreleased] -## [3.0.0a3] - 2024-06-13 +## [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)) +* 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), [#739](https://github.com/stac-utils/stac-fastapi/pull/739)) + +## 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 + +* 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) +* 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)) +* update FastAPI requirement to `>=0.111.0` + +## 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)) + +### 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 ### 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 @@ -15,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 @@ -37,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 @@ -398,11 +468,8 @@ * First PyPi release! -[Unreleased]: -[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 4f22bc78a..4a36342fc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a3 +3.0.0 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dff2035ca..79af024a0 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 @@ -74,6 +78,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 000000000..bc0b03b70 --- /dev/null +++ b/docs/src/migrations/v3.0.0.md @@ -0,0 +1,329 @@ + +# stac-fastapi v3.0 Migration Guide + +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** +- **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 +@attr.s +class CollectionsRequest(APIRequest): + user: Annotated[str, Query(...)] = attr.ib() + +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=..., +) +``` + +## 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: 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: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter + ) + 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) +``` + +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). + +```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, +) +``` + + +## 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/pyproject.toml b/pyproject.toml index b56750993..4786a7dd6 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.0" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 5050d3a7c..7f4b541f7 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/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 5fe7f9d08..5148f2baf 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -18,18 +18,15 @@ 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.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -108,7 +105,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 +211,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): @@ -220,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": { @@ -252,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": { @@ -302,7 +298,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 +327,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 +338,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 +356,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): @@ -439,11 +431,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. @@ -483,8 +470,11 @@ 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.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/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 20a7b4af5..90ed60a4f 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -12,13 +12,14 @@ class ApiExtensions(enum.Enum): Ref: https://github.com/stac-api-extensions """ - context = "context" fields = "fields" filter = "filter" query = "query" sort = "sort" transaction = "transaction" aggregation = "aggregation" + collection_search = "collection-search" + free_text = "free-text" class AddOns(enum.Enum): diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 2716fe7fb..d2e06abca 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,12 +1,12 @@ """Api request/response models.""" -import importlib.util 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 @@ -14,10 +14,17 @@ APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, - str2bbox, - str_to_interval, + Limit, + _bbox_converter, + _datetime_converter, ) +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", @@ -80,18 +87,19 @@ def create_post_request_model( ) -@attr.s # type:ignore +@attr.s 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.ib() @attr.s -class ItemUri(CollectionUri): +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")] = attr.ib() + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() @attr.s @@ -102,63 +110,29 @@ class EmptyRequest(APIRequest): @attr.s -class ItemCollectionUri(CollectionUri): +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) - - -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 - - class GeoJSONResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/geo+json" - - class JSONSchemaResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + 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 + ) - 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/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index ab90ce425..5aad35238 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 bd6f4d9cf..c159faccd 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 0ead30261..5996faa40 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.0a3" + +__version__ = "3.0.0" diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index d559a377a..7db4d9a5e 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 062575915..0ddcb2429 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,17 +1,25 @@ 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 create_get_request_model, create_post_request_model -from stac_fastapi.extensions.core.filter.filter import FilterExtension +from stac_fastapi.api.models import ( + APIRequest, + JSONResponse, + 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 -from stac_fastapi.types.core import NumType +from stac_fastapi.types.core import BaseCoreClient, NumType from stac_fastapi.types.search import BaseSearchPostRequest @@ -190,3 +198,192 @@ 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: + 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 + + 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 + + # With 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()]), + 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": [], + }, + }, + ) + + # 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([]), + search_post_request_model=create_post_request_model([]), + 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: + # 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" + ) + 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 + + +def test_request_model(AsyncTestCoreClient): + """Test if request models are passed correctly.""" + + @attr.s + class CollectionsRequest(APIRequest): + user: Annotated[str, Query(...)] = attr.ib() + + @attr.s + class CollectionRequest(APIRequest): + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() + + @attr.s + class ItemsRequest(APIRequest): + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() + + @attr.s + class ItemRequest(APIRequest): + 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(), + 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 diff --git a/stac_fastapi/api/tests/test_middleware.py b/stac_fastapi/api/tests/test_middleware.py index 041dc410c..00e7f8038 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" diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index cbff0f53d..b0c2ad90e 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -1,17 +1,20 @@ import json import pytest +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", @@ -26,13 +29,36 @@ 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" + + with pytest.raises(HTTPException): + request_model(datetime="yo") + + 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( @@ -40,8 +66,10 @@ def test_create_get_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): @@ -78,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/setup.py b/stac_fastapi/extensions/setup.py index 39bc59b3f..cf49b5d0e 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 7e29e1fd2..7fb122e8c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,9 +1,10 @@ """stac_api.extensions.core module.""" from .aggregation import AggregationExtension -from .context import ContextExtension +from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension 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 @@ -11,12 +12,15 @@ __all__ = ( "AggregationExtension", - "ContextExtension", "FieldsExtension", "FilterExtension", + "FreeTextExtension", + "FreeTextAdvancedExtension", "PaginationExtension", "QueryExtension", "SortExtension", "TokenPaginationExtension", "TransactionExtension", + "CollectionSearchExtension", + "CollectionSearchPostExtension", ) 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 fcab3323f..4e72e0005 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,39 @@ """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 fastapi import Query +from pydantic import Field +from typing_extensions import Annotated + +from stac_fastapi.types.search import ( + BaseSearchGetRequest, + BaseSearchPostRequest, + str2list, ) -from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest + + +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, FilterExtensionGetRequest): +class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Optional[str] = attr.ib(default=None) + aggregations: Optional[List[str]] = attr.ib(default=None, converter=_agg_converter) -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]] = Field( + default=None, + description="A list of aggregations to compute and return.", + ) 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 000000000..eed6d5020 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py @@ -0,0 +1,13 @@ +"""Collection-Search extension module.""" + +from .collection_search import ( + CollectionSearchExtension, + CollectionSearchPostExtension, + 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 000000000..ac148dfb4 --- /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 new file mode 100644 index 000000000..2927cd822 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py @@ -0,0 +1,134 @@ +"""Collection-Search extension.""" + +from enum import Enum +from typing import List, Optional, Union + +import attr +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 .client import AsyncBaseCollectionSearchClient, BaseCollectionSearchClient +from .request import BaseCollectionSearchGetRequest, BaseCollectionSearchPostRequest + + +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: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest) + 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 + + +@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 new file mode 100644 index 000000000..0bc6d22e3 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -0,0 +1,139 @@ +"""Request models for the Collection-Search extension.""" + +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, + Limit, + _bbox_converter, + _datetime_converter, +) + + +@attr.s +class BaseCollectionSearchGetRequest(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[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/stac_fastapi/extensions/core/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py deleted file mode 100644 index 4037ba938..000000000 --- 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 e08572ca0..02d3dd197 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,11 @@ """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 from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list @@ -42,40 +43,32 @@ 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[ + 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: Optional[str] = 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 dde015307..30ac011b0 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -3,7 +3,9 @@ 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 @@ -14,14 +16,62 @@ 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( + 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="cql-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/free_text/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/__init__.py new file mode 100644 index 000000000..53906bc11 --- /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 000000000..8b61b32df --- /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 000000000..07aa7be86 --- /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/stac_fastapi/extensions/core/pagination/pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py index 296e9ae6a..7959b0357 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 000000000..66391c7f9 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -0,0 +1,36 @@ +"""Pagination extension request models.""" + +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 +class GETTokenPagination(APIRequest): + """Token pagination for GET requests.""" + + token: Annotated[Optional[str], Query()] = 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: Annotated[Optional[str], Query()] = 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 d3fa10391..11ccfb35b 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): 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 dcb162060..472c385b4 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 7f8425e70..ad7f461c3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -3,7 +3,9 @@ from typing import Any, Dict, Optional import attr -from pydantic import BaseModel +from fastapi import Query +from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest @@ -12,10 +14,24 @@ class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Optional[str] = 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 377067ff9..e1c22eea3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,23 +1,49 @@ -# encoding: utf-8 """Request model for the Sort Extension.""" from typing import List, Optional import attr -from pydantic import BaseModel +from fastapi import Query +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: Optional[str] = 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/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 44721be04..098147a28 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -232,7 +232,9 @@ def register_delete_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["DELETE"], - endpoint=create_async_endpoint(self.client.delete_collection, CollectionUri), + endpoint=create_async_endpoint( + self.client.delete_collection, CollectionUri + ), ) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 0ead30261..5996faa40 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.0a3" + +__version__ = "3.0.0" diff --git a/stac_fastapi/extensions/tests/test_aggregation.py b/stac_fastapi/extensions/tests/test_aggregation.py index c96e316ae..480cc669f 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_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py new file mode 100644 index 000000000..edc292210 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -0,0 +1,394 @@ +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, + 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 ( + 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.types import stac +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 + + +@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 GET - /collections endpoint with collection-search ext.""" + api = StacApi( + settings=ApiSettings(), + client=DummyCoreClient(), + extensions=[CollectionSearchExtension()], + collections_get_request_model=BaseCollectionSearchGetRequest, + ) + 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 GET - /collections endpoint with collection-search ext + with additional models. + """ + collections_get_request_model = create_request_model( + model_name="SearchGetRequest", + base_model=BaseCollectionSearchGetRequest, + mixins=[ + FreeTextExtensionGetRequest, + FilterExtensionGetRequest, + QueryExtensionGetRequest, + SortExtensionGetRequest, + FieldsExtensionGetRequest, + ], + request_type="GET", + ) + + api = StacApi( + settings=ApiSettings(), + client=DummyCoreClient(), + extensions=[ + CollectionSearchExtension( + GET=collections_get_request_model, + 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"] + + +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"] diff --git a/stac_fastapi/extensions/tests/test_filter.py b/stac_fastapi/extensions/tests/test_filter.py new file mode 100644 index 000000000..a13fb14c9 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_filter.py @@ -0,0 +1,119 @@ +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): + _ = kwargs.pop("request", None) + return kwargs + + 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 + + +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/extensions/tests/test_free_text.py b/stac_fastapi/extensions/tests/test_free_text.py new file mode 100644 index 000000000..55f253a34 --- /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" diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index c8f2f9df6..9fa0ad9ee 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", diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 6d536b94e..5d3399a7b 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 @@ -1101,18 +1099,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/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cf6647340..cfa8baf9b 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -2,11 +2,11 @@ """ -import abc from typing import Dict, List, Optional, Union import attr -from pydantic import PositiveInt +from fastapi import Query +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,14 +38,76 @@ 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] Limit = Annotated[PositiveInt, AfterValidator(crop)] -@attr.s # type:ignore -class APIRequest(abc.ABC): +@attr.s +class APIRequest: """Generic API Request base class.""" def kwargs(self) -> Dict: @@ -56,15 +120,71 @@ def kwargs(self) -> Dict: 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: 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( + 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[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) 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 (capped to 10_000).", # noqa: E501 + ) diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 0ead30261..5996faa40 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.0a3" + +__version__ = "3.0.0" diff --git a/stac_fastapi/types/tests/test_limit.py b/stac_fastapi/types/tests/test_limit.py index e5b2125bd..d4c03f33e 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