From 8169e86092e945e069f072589c0c8721f6ba0cb4 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 10 Apr 2024 19:18:26 +0200 Subject: [PATCH 01/57] add python 3.12 support and some docker cleanup (#654) * add python 3.12 support and some docker cleanup * :facepalm: --- .github/workflows/cicd.yaml | 42 +++++++---------------------- .github/workflows/deploy_mkdocs.yml | 10 +------ Dockerfile | 6 ++--- Dockerfile.docs | 4 +-- Makefile | 10 +++---- README.md | 12 ++++----- stac_fastapi/api/setup.py | 4 +++ stac_fastapi/extensions/setup.py | 4 +++ stac_fastapi/types/setup.py | 4 +++ 9 files changed, 38 insertions(+), 58 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index f86cb6786..641be715d 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -10,33 +10,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] timeout-minutes: 20 - services: - db_service: - image: ghcr.io/stac-utils/pgstac:v0.7.1 - env: - POSTGRES_USER: username - POSTGRES_PASSWORD: password - POSTGRES_DB: postgis - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - PGUSER: username - PGPASSWORD: password - PGDATABASE: postgis - ALLOW_IP_RANGE: 0.0.0.0/0 - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 10s - --health-retries 10 - --log-driver none - ports: - # Maps tcp port 5432 on service container to the host - - 5432:5432 - steps: - name: Check out repository code uses: actions/checkout@v4 @@ -55,18 +31,18 @@ jobs: - name: Install types run: | - pip install ./stac_fastapi/types[dev] + python -m pip install ./stac_fastapi/types[dev] - name: Install core api run: | - pip install ./stac_fastapi/api[dev] + python -m pip install ./stac_fastapi/api[dev] - name: Install Extensions run: | - pip install ./stac_fastapi/extensions[dev] + python -m pip install ./stac_fastapi/extensions[dev] - name: Test - run: pytest -svvv + run: python -m pytest -svvv env: ENVIRONMENT: testing @@ -93,14 +69,14 @@ jobs: run: | python -m pip install ./stac_fastapi/types[dev] - - name: Install extensions - run: | - python -m pip install ./stac_fastapi/extensions - - name: Install core api run: | python -m pip install ./stac_fastapi/api[dev,benchmark] + - name: Install extensions + run: | + python -m pip install ./stac_fastapi/extensions + - name: Run Benchmark run: python -m pytest stac_fastapi/api/tests/benchmarks.py --benchmark-only --benchmark-columns 'min, max, mean, median' --benchmark-json output.json diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 7132fdb6c..4715015e9 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -29,8 +29,8 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install \ - stac_fastapi/api[docs] \ stac_fastapi/types[docs] \ + stac_fastapi/api[docs] \ stac_fastapi/extensions[docs] \ - name: update API docs @@ -40,14 +40,6 @@ jobs: --exclude_source \ --overwrite \ stac_fastapi - env: - POSTGRES_USER: username - POSTGRES_PASS: password - POSTGRES_DBNAME: postgis - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - POSTGRES_HOST_READER: localhost - POSTGRES_HOST_WRITER: localhost - name: Deploy docs run: mkdocs gh-deploy --force -f docs/mkdocs.yml diff --git a/Dockerfile b/Dockerfile index 2187ac53e..501de7f36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,6 @@ WORKDIR /app COPY . /app -RUN pip install -e ./stac_fastapi/types[dev] && \ - pip install -e ./stac_fastapi/api[dev] && \ - pip install -e ./stac_fastapi/extensions[dev] +RUN python -m pip install -e ./stac_fastapi/types[dev] && \ + python -m pip install -e ./stac_fastapi/api[dev] && \ + python -m pip install -e ./stac_fastapi/extensions[dev] diff --git a/Dockerfile.docs b/Dockerfile.docs index e3c7447e5..caa0f7e9f 100644 --- a/Dockerfile.docs +++ b/Dockerfile.docs @@ -11,8 +11,8 @@ COPY . /opt/src WORKDIR /opt/src RUN python -m pip install \ - stac_fastapi/api \ stac_fastapi/types \ + stac_fastapi/api \ stac_fastapi/extensions CMD ["pdocs", \ @@ -21,4 +21,4 @@ CMD ["pdocs", \ "docs/api/", \ "--exclude_source", \ "--overwrite", \ - "stac_fastapi"] \ No newline at end of file + "stac_fastapi"] diff --git a/Makefile b/Makefile index e802fbb54..eef5dae35 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,10 @@ image: .PHONY: install install: - pip install wheel && \ - pip install -e ./stac_fastapi/api[dev] && \ - pip install -e ./stac_fastapi/types[dev] && \ - pip install -e ./stac_fastapi/extensions[dev] + python -m pip install wheel && \ + python -m pip install -e ./stac_fastapi/types[dev] && \ + python -m pip install -e ./stac_fastapi/api[dev] && \ + python -m pip install -e ./stac_fastapi/extensions[dev] .PHONY: docs-image docs-image: @@ -21,4 +21,4 @@ docs: docs-image .PHONY: test test: image - pytest . \ No newline at end of file + python -m pytest . diff --git a/README.md b/README.md index 350ce2589..9a8ec78ed 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,12 @@ Backends are hosted in their own repositories: ```bash # Install from PyPI -pip install stac-fastapi.api stac-fastapi.types stac-fastapi.extensions +python -m pip install stac-fastapi.types stac-fastapi.api stac-fastapi.extensions # Install a backend of your choice -pip install stac-fastapi.sqlalchemy +python -m pip install stac-fastapi.sqlalchemy # or -pip install stac-fastapi.pgstac +python -m pip install stac-fastapi.pgstac ``` Other backends may be available from other sources, search [PyPI](https://pypi.org/) for more. @@ -60,14 +60,14 @@ Other backends may be available from other sources, search [PyPI](https://pypi.o Install the packages in editable mode: ```shell -pip install -e \ - 'stac_fastapi/api[dev]' \ +python -m pip install -e \ 'stac_fastapi/types[dev]' \ + 'stac_fastapi/api[dev]' \ 'stac_fastapi/extensions[dev]' ``` To run the tests: ```shell -pytest +python -m pytest ``` diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 9dfa86ac9..a5bfd897e 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -41,6 +41,10 @@ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index a70ea5855..af564931b 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -36,6 +36,10 @@ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 9a06fda95..c3905ede5 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -37,6 +37,10 @@ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", From a262115a1fee156525ada07c1e712735282c7cb4 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Wed, 10 Apr 2024 19:27:03 +0200 Subject: [PATCH 02/57] Adding queryables link to landing page (#587) * Adding queryables link to landing page * Adding info to changelog * Fixing schemajson -> jsonschema * use `extension_is_enabled` --------- Co-authored-by: Pete Gadomski Co-authored-by: Vincent Sarago --- CHANGES.md | 1 + stac_fastapi/types/stac_fastapi/types/core.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 0b5d3f192..12c776368 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### Added * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) +* Add `/queryables` link to the landing page ([#587](https://github.com/stac-utils/stac-fastapi/pull/587)) ### Changed diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 79b18f57b..a704ae49b 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -369,6 +369,18 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: extension_schemas=[], ) + # Add Queryables link + if self.extension_is_enabled("FilterExtension"): + landing_page["links"].append( + { + "rel": Relations.queryables.value, + "type": MimeTypes.jsonschema, + "title": "Queryables", + "href": urljoin(base_url, "queryables"), + "method": "GET", + } + ) + # Add Collections links collections = self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: @@ -566,6 +578,20 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: conformance_classes=self.conformance_classes(), extension_schemas=[], ) + + # Add Queryables link + if self.extension_is_enabled("FilterExtension"): + landing_page["links"].append( + { + "rel": Relations.queryables.value, + "type": MimeTypes.jsonschema, + "title": "Queryables", + "href": urljoin(base_url, "queryables"), + "method": "GET", + } + ) + + # Add Collections links collections = await self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: landing_page["links"].append( From 315cfaefef2ab2773ae087228439ba0371fb7372 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 11 Apr 2024 06:19:03 +0200 Subject: [PATCH 03/57] replace inexistent enum with string while we wait for stac-pydantic update (#656) --- stac_fastapi/types/stac_fastapi/types/core.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index a704ae49b..d915d465f 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -373,8 +373,10 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: if self.extension_is_enabled("FilterExtension"): landing_page["links"].append( { - "rel": Relations.queryables.value, - "type": MimeTypes.jsonschema, + # TODO: replace this with Relations.queryables.value, + "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables", + # TODO: replace this with MimeTypes.jsonschema, + "type": "application/schema+json", "title": "Queryables", "href": urljoin(base_url, "queryables"), "method": "GET", @@ -583,8 +585,10 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: if self.extension_is_enabled("FilterExtension"): landing_page["links"].append( { - "rel": Relations.queryables.value, - "type": MimeTypes.jsonschema, + # TODO: replace this with Relations.queryables.value, + "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables", + # TODO: replace this with MimeTypes.jsonschema, + "type": "application/schema+json", "title": "Queryables", "href": urljoin(base_url, "queryables"), "method": "GET", From 96de0e27f1c1c683947019d33e3e4a72c43bf2a5 Mon Sep 17 00:00:00 2001 From: Matthias Mohr Date: Thu, 11 Apr 2024 16:53:31 +0200 Subject: [PATCH 04/57] Configure the landing page id, description, etc. via env vars (#639) * Allow an easy way to configure the landing page id, description, title and version via env variables * use pydantic settings (#657) * use pydantic settings * rename stac_fastapi_id to stac_fastapi_landing_id * Update docs/src/tips-and-tricks.md --------- Co-authored-by: vincentsarago --- CHANGES.md | 1 + docs/src/tips-and-tricks.md | 9 +++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 19 ++++++++++++++++--- .../types/stac_fastapi/types/config.py | 5 +++++ stac_fastapi/types/stac_fastapi/types/core.py | 9 ++++++--- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 12c776368..8856da996 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) * Add `/queryables` link to the landing page ([#587](https://github.com/stac-utils/stac-fastapi/pull/587)) +- `id`, `title`, `description` and `api_version` fields can be customized via env variables ### Changed diff --git a/docs/src/tips-and-tricks.md b/docs/src/tips-and-tricks.md index ca5463c59..a42ce98b2 100644 --- a/docs/src/tips-and-tricks.md +++ b/docs/src/tips-and-tricks.md @@ -31,3 +31,12 @@ from stac_fastapi.extensions.core.context import ContextExtension ``` and then edit the `api = StacApi(...` call to add `ContextExtension()` to the list given as the `extensions` parameter. + +## Set API title, description and version + +For the landing page, you can set the API title, description and version using environment variables. + +- `STAC_FASTAPI_VERSION` (string) is the version number of your API instance (this is not the STAC version). +- `STAC FASTAPI_TITLE` (string) should be a self-explanatory title for your API. +- `STAC FASTAPI_DESCRIPTION` (string) should be a good description for your API. It can contain CommonMark. +- `STAC_FASTAPI_LANDING_ID` (string) is a unique identifier for your Landing page. diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 557896d8f..7ad0c96f5 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -1,4 +1,5 @@ """Fastapi app creation.""" + from typing import Any, Dict, List, Optional, Tuple, Type, Union import attr @@ -83,10 +84,22 @@ class StacApi: converter=update_openapi, ) router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) - title: str = attr.ib(default="stac-fastapi") - api_version: str = attr.ib(default="0.1") + title: str = attr.ib( + default=attr.Factory( + lambda self: self.settings.stac_fastapi_title, takes_self=True + ) + ) + api_version: str = attr.ib( + default=attr.Factory( + lambda self: self.settings.stac_fastapi_version, takes_self=True + ) + ) stac_version: str = attr.ib(default=STAC_VERSION) - description: str = attr.ib(default="stac-fastapi") + description: str = attr.ib( + default=attr.Factory( + lambda self: self.settings.stac_fastapi_description, takes_self=True + ) + ) search_get_request_model: Type[BaseSearchGetRequest] = attr.ib( default=BaseSearchGetRequest ) diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index b3f22fb65..4b88c56a4 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -22,6 +22,11 @@ class ApiSettings(BaseSettings): # `pydantic.BaseSettings` instead default_includes: Optional[Set[str]] = None + stac_fastapi_title: str = "stac-fastapi" + stac_fastapi_description: str = "stac-fastapi" + stac_fastapi_version: str = "0.1" + stac_fastapi_landing_id: str = "stac-fastapi" + app_host: str = "0.0.0.0" app_port: int = 8000 reload: bool = True diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index d915d465f..77078ace3 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -12,6 +12,7 @@ from starlette.responses import Response from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.requests import get_base_url @@ -22,6 +23,8 @@ NumType = Union[float, int] StacType = Dict[str, Any] +api_settings = ApiSettings() + @attr.s # type:ignore class BaseTransactionsClient(abc.ABC): @@ -255,9 +258,9 @@ class LandingPageMixin(abc.ABC): """Create a STAC landing page (GET /).""" stac_version: str = attr.ib(default=STAC_VERSION) - landing_page_id: str = attr.ib(default="stac-fastapi") - title: str = attr.ib(default="stac-fastapi") - description: str = attr.ib(default="stac-fastapi") + landing_page_id: str = attr.ib(default=api_settings.stac_fastapi_landing_id) + title: str = attr.ib(default=api_settings.stac_fastapi_title) + description: str = attr.ib(default=api_settings.stac_fastapi_description) def _landing_page( self, From f7d2eb3c30bef338e3bf6cce5c425b531a653a5e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 11 Apr 2024 23:17:31 +0200 Subject: [PATCH 05/57] add deprecation warning for the ContextExtension (#658) --- CHANGES.md | 1 + docs/src/tips-and-tricks.md | 5 +++++ .../extensions/stac_fastapi/extensions/core/context.py | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 8856da996..18f01c0e5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) * Add `/queryables` link to the landing page ([#587](https://github.com/stac-utils/stac-fastapi/pull/587)) - `id`, `title`, `description` and `api_version` fields can be customized via env variables +* Add `DeprecationWarning` for the `ContextExtension` ### Changed diff --git a/docs/src/tips-and-tricks.md b/docs/src/tips-and-tricks.md index a42ce98b2..a5ca8cb68 100644 --- a/docs/src/tips-and-tricks.md +++ b/docs/src/tips-and-tricks.md @@ -22,6 +22,10 @@ If needed, you can edit the `allow_origins` parameter to only allow CORS request ## Enable the Context extension +!!! Warning + + The `ContextExtension` is deprecated and will be removed in 3.0. See https://github.com/radiantearth/stac-api-spec/issues/396 + The Context STAC extension provides information on the number of items matched and returned from a STAC search. This is required by various other STAC-related tools, such as the pystac command-line client. To enable the extension, edit your backend's `app.py` and add the following import: @@ -30,6 +34,7 @@ To enable the extension, edit your backend's `app.py` and add the following impo from stac_fastapi.extensions.core.context import ContextExtension ``` + and then edit the `api = StacApi(...` call to add `ContextExtension()` to the list given as the `extensions` parameter. ## Set API title, description and version diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py index 90faae914..b65394875 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py @@ -1,4 +1,6 @@ """Context extension.""" + +import warnings from typing import List, Optional import attr @@ -24,6 +26,14 @@ class ContextExtension(ApiExtension): 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.warm( + "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. From bc5263542595cb82076da2bc6428e97fea2f44bc Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 17 Apr 2024 08:26:49 +0200 Subject: [PATCH 06/57] prepare release 2.5.0 (#659) * prepare release 2.5.0 * Update CHANGES.md --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 5 ++++- RELEASING.md | 2 +- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 18f01c0e5..f043635c0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.5.0] - 2024-04-12 + ### Added * Add benchmark in CI ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) @@ -306,7 +308,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.0]: [2.4.9]: [2.4.8]: [2.4.7]: diff --git a/RELEASING.md b/RELEASING.md index 8aa14afcb..afd834262 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,7 @@ This is a checklist for releasing a new version of **stac-fastapi**. 1. Determine the next version. We currently do not have published versioning guidelines, but there is some text on the subject here: . 2. Create a release branch named `release/vX.Y.Z`, where `X.Y.Z` is the new version. -3. Search and replace all instances of the current version number with the new version. As of this writing, there's five different `version.py` files, and one `VERSION` file, in the repo. +3. Search and replace all instances of the current version number with the new version. As of this writing, there's 3 different `version.py` files, and one `VERSION` file, in the repo. 4. Update [CHANGES.md](./CHANGES.md) for the new version. Add the appropriate header, and update the links at the bottom of the file. 5. Audit CHANGES.md for completeness and accuracy. Also, ensure that the changes in this version are appropriate for the version number change (i.e. if you're making breaking changes, you should be increasing the `MAJOR` version number). 6. (optional) If you have permissions, run `scripts/publish --test` to test your PyPI publish. If successful, the published packages will be available on . diff --git a/VERSION b/VERSION index 158349812..437459cd9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.9 \ No newline at end of file +2.5.0 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index bb0c7c379..1e9f3de42 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.4.9" +__version__ = "2.5.0" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index bb0c7c379..1e9f3de42 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.4.9" +__version__ = "2.5.0" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index bb0c7c379..1e9f3de42 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.4.9" +__version__ = "2.5.0" From 1078c2d3ea708e9989f0bd72e519ea457938bcdc Mon Sep 17 00:00:00 2001 From: Jon Lantsberger Date: Thu, 18 Apr 2024 00:31:43 -0500 Subject: [PATCH 07/57] Update context.py (#660) * Update context.py typo in warning, "warm" insteand of "warn" * Update routes.py fixing warn call * Update CHANGES.md * Update CHANGES.md * Update CHANGES.md --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 4 ++++ stac_fastapi/api/stac_fastapi/api/routes.py | 2 +- .../extensions/stac_fastapi/extensions/core/context.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f043635c0..2cda0a753 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +* Fixed warnings.warn deprecation syntax for response class and the context extension ([#660](https://github.com/stac-utils/stac-fastapi/pull/660)) + ## [2.5.0] - 2024-04-12 ### Added diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index 66b76d2d7..df4a136eb 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -45,7 +45,7 @@ def create_async_endpoint( """ if response_class: - warnings.warns( + warnings.warn( "`response_class` option is deprecated, please set the Response class directly in the endpoint.", # noqa: E501 DeprecationWarning, ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py index b65394875..4037ba938 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py @@ -28,7 +28,7 @@ class ContextExtension(ApiExtension): def __attrs_post_init__(self): """init.""" - warnings.warm( + warnings.warn( "The ContextExtension is deprecated and will be removed in 3.0.", DeprecationWarning, stacklevel=1, From 27a8eddd64f0b53bf33a26923f0d0b9592d8d048 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 18 Apr 2024 13:50:41 +0800 Subject: [PATCH 08/57] v2.5.1 release (#661) --- CHANGES.md | 5 ++++- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2cda0a753..6bf2c50b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.5.1] - 2024-04-18 + ### Fixed * Fixed warnings.warn deprecation syntax for response class and the context extension ([#660](https://github.com/stac-utils/stac-fastapi/pull/660)) @@ -312,7 +314,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.1]: [2.5.0]: [2.4.9]: [2.4.8]: diff --git a/VERSION b/VERSION index 437459cd9..73462a5a1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.0 +2.5.1 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 1e9f3de42..3546e213f 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.0" +__version__ = "2.5.1" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 1e9f3de42..3546e213f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.0" +__version__ = "2.5.1" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 1e9f3de42..3546e213f 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.0" +__version__ = "2.5.1" From 1148c9db529728795c74aef722b253761638e85a Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 18 Apr 2024 23:03:13 +0800 Subject: [PATCH 09/57] fix datetime validator (#662) * fix datetime validator * lint code * fix pull request # --- CHANGES.md | 4 ++++ .../types/stac_fastapi/types/rfc3339.py | 17 +++++++++++------ stac_fastapi/types/tests/test_rfc3339.py | 7 +++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6bf2c50b5..e3a91da62 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +* BaseSearchGetRequest datetime validator str_to_interval not allowing GET /search requests with datetime = None ([#662](https://github.com/stac-utils/stac-fastapi/pull/662)) + ## [2.5.1] - 2024-04-18 ### Fixed diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 43baa8d53..b1d40999e 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -45,9 +45,7 @@ def rfc3339_str_to_datetime(s: str) -> datetime: return iso8601.parse_date(s) -def str_to_interval( - interval: str, -) -> Optional[DateTimeType]: +def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: """Extract a tuple of datetimes from an interval string. Interval strings are defined by @@ -56,12 +54,19 @@ def str_to_interval( or end (but not both) to be open-ended with '..' or ''. Args: - interval (str) : The interval string to convert to a :class:`datetime.datetime` - tuple. + interval (str or None): The interval string to convert to a tuple of + datetime.datetime objects, or None if no datetime is specified. + + Returns: + Optional[DateTimeType]: A tuple of datetime.datetime objects or None if + input is None. Raises: - ValueError: If the string is not a valid interval string. + ValueError: If the string is not a valid interval string and not None. """ + if interval is None: + return None + if not interval: raise ValueError("Empty interval string is invalid.") diff --git a/stac_fastapi/types/tests/test_rfc3339.py b/stac_fastapi/types/tests/test_rfc3339.py index 0a402699a..23f6242bc 100644 --- a/stac_fastapi/types/tests/test_rfc3339.py +++ b/stac_fastapi/types/tests/test_rfc3339.py @@ -103,3 +103,10 @@ def test_now_functions() -> None: assert now1.tzinfo == timezone.utc rfc3339_str_to_datetime(now_to_rfc3339_str()) + + +def test_str_to_interval_with_none(): + """Test that str_to_interval returns None when provided with None.""" + assert ( + str_to_interval(None) is None + ), "str_to_interval should return None when input is None" From 2e14348e9e9d37904b065156c8155b57de99e3b9 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Fri, 19 Apr 2024 00:37:56 +0800 Subject: [PATCH 10/57] update for v2.5.2 (#663) --- CHANGES.md | 5 ++++- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e3a91da62..51b968156 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.5.2] - 2024-04-19 + ### Fixed * BaseSearchGetRequest datetime validator str_to_interval not allowing GET /search requests with datetime = None ([#662](https://github.com/stac-utils/stac-fastapi/pull/662)) @@ -318,7 +320,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.2]: [2.5.1]: [2.5.0]: [2.4.9]: diff --git a/VERSION b/VERSION index 73462a5a1..f225a78ad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 +2.5.2 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 3546e213f..1b1206cd3 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.1" +__version__ = "2.5.2" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 3546e213f..1b1206cd3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.1" +__version__ = "2.5.2" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 3546e213f..1b1206cd3 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.1" +__version__ = "2.5.2" From dddd7104865db56c59ea21d75ba939891249d4ea Mon Sep 17 00:00:00 2001 From: Stijn Caerts Date: Mon, 22 Apr 2024 17:14:58 +0200 Subject: [PATCH 11/57] apply datetime converter in ItemCollection endpoint model (#667) * apply datetime converter in ItemCollection endpoint model * update changelog --- CHANGES.md | 4 ++++ stac_fastapi/api/stac_fastapi/api/models.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 51b968156..1a5fc8494 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +* Apply datetime converter in ItemCollection endpoint model ([#667](https://github.com/stac-utils/stac-fastapi/pull/667)) + ## [2.5.2] - 2024-04-19 ### Fixed diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 53f376aa0..c1d70b3cf 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -16,6 +16,7 @@ BaseSearchGetRequest, BaseSearchPostRequest, str2bbox, + str_to_interval, ) @@ -127,7 +128,7 @@ class ItemCollectionUri(CollectionUri): limit: int = attr.ib(default=10) bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - datetime: Optional[DateTimeType] = attr.ib(default=None) + datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) class POSTTokenPagination(BaseModel): From 085bcd700772ec0c952e439cf3a96a4192608c5d Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Mon, 22 Apr 2024 23:28:09 +0800 Subject: [PATCH 12/57] Remove str2list converter from BaseSearchGetRequest (#668) * remove str2list converter * update changelog --- CHANGES.md | 3 ++- stac_fastapi/types/stac_fastapi/types/search.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1a5fc8494..3c5552602 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,9 @@ ## [Unreleased] -### Fixed +### Fixed +* Remove the str2list converter from intersection queries via BaseSearchGetRequest ([#668](https://github.com/stac-utils/stac-fastapi/pull/668)) * Apply datetime converter in ItemCollection endpoint model ([#667](https://github.com/stac-utils/stac-fastapi/pull/667)) ## [2.5.2] - 2024-04-19 diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 0851c1d30..fb847349e 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -108,7 +108,7 @@ class BaseSearchGetRequest(APIRequest): 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, converter=str2list) + 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) From cae227840ee231ecfdb9b4ef1d187142dfe534ce Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Tue, 23 Apr 2024 01:11:06 +0800 Subject: [PATCH 13/57] v2.5.3 (#669) --- CHANGES.md | 5 ++++- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3c5552602..57498b21f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.5.3] - 2024-04-23 + ### Fixed * Remove the str2list converter from intersection queries via BaseSearchGetRequest ([#668](https://github.com/stac-utils/stac-fastapi/pull/668)) @@ -325,7 +327,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.3]: [2.5.2]: [2.5.1]: [2.5.0]: diff --git a/VERSION b/VERSION index f225a78ad..aedc15bb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.2 +2.5.3 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 1b1206cd3..1552b22f1 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.2" +__version__ = "2.5.3" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 1b1206cd3..1552b22f1 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.2" +__version__ = "2.5.3" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 1b1206cd3..1552b22f1 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.2" +__version__ = "2.5.3" From 0bd592e8f5c87a51011a03bbd6592d254f1a2a4f Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Wed, 24 Apr 2024 15:01:11 +0800 Subject: [PATCH 14/57] return 400 for datetime errors (#670) * return HTTPException * update test * update validate interval format * update changelog * remove validate interval function * catch iso8601.ParseError --- CHANGES.md | 4 + .../types/stac_fastapi/types/rfc3339.py | 78 ++++++++++++------- stac_fastapi/types/tests/test_rfc3339.py | 6 +- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 57498b21f..0f057de71 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +* Return 400 for datetime errors ([#670](https://github.com/stac-utils/stac-fastapi/pull/670)) + ## [2.5.3] - 2024-04-23 ### Fixed diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index b1d40999e..1277c998a 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -4,6 +4,7 @@ from typing import Optional, Tuple, Union import iso8601 +from fastapi import HTTPException from pystac.utils import datetime_to_str RFC33339_PATTERN = ( @@ -45,53 +46,74 @@ def rfc3339_str_to_datetime(s: str) -> datetime: return iso8601.parse_date(s) -def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: - """Extract a tuple of datetimes from an interval string. +def parse_single_date(date_str: str) -> datetime: + """ + Parse a single RFC3339 date string into a datetime object. + + Args: + date_str (str): A string representing the date in RFC3339 format. + + Returns: + datetime: A datetime object parsed from the date_str. + + Raises: + ValueError: If the date_str is empty or contains the placeholder '..'. + """ + if ".." in date_str or not date_str: + raise ValueError("Invalid date format.") + return rfc3339_str_to_datetime(date_str) + - Interval strings are defined by - OGC API - Features Part 1 for the datetime query parameter value. These follow the - form '1985-04-12T23:20:50.52Z/1986-04-12T23:20:50.52Z', and allow either the start - or end (but not both) to be open-ended with '..' or ''. +def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: + """ + Extract a tuple of datetime objects from an interval string defined by the OGC API. + The interval can either be a single datetime or a range with start and end datetime. Args: - interval (str or None): The interval string to convert to a tuple of - datetime.datetime objects, or None if no datetime is specified. + interval (Optional[str]): The interval string to convert to datetime objects, + or None if no datetime is specified. Returns: - Optional[DateTimeType]: A tuple of datetime.datetime objects or None if - input is None. + Optional[DateTimeType]: A tuple of datetime.datetime objects or + None if input is None. Raises: - ValueError: If the string is not a valid interval string and not None. + HTTPException: If the string is not valid for various reasons such as being empty, + having more than one slash, or if date formats are invalid. """ if interval is None: return None if not interval: - raise ValueError("Empty interval string is invalid.") + raise HTTPException(status_code=400, detail="Empty interval string is invalid.") values = interval.split("/") - if len(values) == 1: - # Single date for == date case - return rfc3339_str_to_datetime(values[0]) - elif len(values) > 2: - raise ValueError( - f"Interval string '{interval}' contains more than one forward slash." + if len(values) > 2: + raise HTTPException( + status_code=400, + detail="Interval string contains more than one forward slash.", ) - start = None - end = None - if values[0] not in ["..", ""]: - start = rfc3339_str_to_datetime(values[0]) - if values[1] not in ["..", ""]: - end = rfc3339_str_to_datetime(values[1]) + try: + start = parse_single_date(values[0]) if values[0] not in ["..", ""] else None + end = ( + parse_single_date(values[1]) + if len(values) > 1 and values[1] not in ["..", ""] + else None + ) + except (ValueError, iso8601.ParseError) as e: + raise HTTPException(status_code=400, detail=str(e)) if start is None and end is None: - raise ValueError("Double open-ended intervals are not allowed.") + raise HTTPException( + status_code=400, detail="Double open-ended intervals are not allowed." + ) if start is not None and end is not None and start > end: - raise ValueError("Start datetime cannot be before end datetime.") - else: - return start, end + raise HTTPException( + status_code=400, detail="Start datetime cannot be before end datetime." + ) + + return start, end def now_in_utc() -> datetime: diff --git a/stac_fastapi/types/tests/test_rfc3339.py b/stac_fastapi/types/tests/test_rfc3339.py index 23f6242bc..8d83dbb9d 100644 --- a/stac_fastapi/types/tests/test_rfc3339.py +++ b/stac_fastapi/types/tests/test_rfc3339.py @@ -1,6 +1,7 @@ from datetime import timezone import pytest +from fastapi import HTTPException from stac_fastapi.types.rfc3339 import ( now_in_utc, @@ -86,8 +87,11 @@ def test_parse_valid_str_to_datetime(test_input): @pytest.mark.parametrize("test_input", invalid_intervals) def test_parse_invalid_interval_to_datetime(test_input): - with pytest.raises(ValueError): + with pytest.raises(HTTPException) as exc_info: str_to_interval(test_input) + assert ( + exc_info.value.status_code == 400 + ), "Should return a 400 status code for invalid intervals" @pytest.mark.parametrize("test_input", valid_intervals) From 108fc2cb27aafd7c6a58c1c4c85968292e543bc1 Mon Sep 17 00:00:00 2001 From: Arjan Date: Wed, 24 Apr 2024 09:08:56 +0200 Subject: [PATCH 15/57] Fix PUT collections (#666) Co-authored-by: Arjan van Bentem Co-authored-by: Jonathan Healy --- CHANGES.md | 3 +- stac_fastapi/api/stac_fastapi/api/models.py | 4 +- .../extensions/core/transaction.py | 9 +- .../extensions/tests/test_transaction.py | 87 ++++++++++++++++--- stac_fastapi/types/stac_fastapi/types/core.py | 8 +- 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0f057de71..ca786b01a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,11 +4,12 @@ ### Fixed +* Fix missing payload for the PUT `collection/{collection_id}` endpoint ([#665](https://github.com/stac-utils/stac-fastapi/issues/665)) * Return 400 for datetime errors ([#670](https://github.com/stac-utils/stac-fastapi/pull/670)) ## [2.5.3] - 2024-04-23 -### Fixed +### Fixed * Remove the str2list converter from intersection queries via BaseSearchGetRequest ([#668](https://github.com/stac-utils/stac-fastapi/pull/668)) * Apply datetime converter in ItemCollection endpoint model ([#667](https://github.com/stac-utils/stac-fastapi/pull/667)) diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index c1d70b3cf..0721413a9 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -103,14 +103,14 @@ def create_post_request_model( @attr.s # type:ignore class CollectionUri(APIRequest): - """Delete collection.""" + """Get or delete collection.""" collection_id: str = attr.ib(default=Path(..., description="Collection ID")) @attr.s class ItemUri(CollectionUri): - """Delete item.""" + """Get or delete item.""" item_id: str = attr.ib(default=Path(..., description="Item ID")) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 0ebcc6194..86e1bfc52 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -31,6 +31,13 @@ class PutItem(ItemUri): item: stac_types.Item = attr.ib(default=Body(None)) +@attr.s +class PutCollection(CollectionUri): + """Update Collection.""" + + collection: stac_types.Collection = attr.ib(default=Body(None)) + + @attr.s class TransactionExtension(ApiExtension): """Transaction Extension. @@ -128,7 +135,7 @@ def register_update_collection(self): response_model_exclude_none=True, methods=["PUT"], endpoint=create_async_endpoint( - self.client.update_collection, stac_types.Collection + self.client.update_collection, PutCollection ), ) diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index fc5acc2cf..e6416eaea 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -8,7 +8,7 @@ from stac_fastapi.extensions.core import TransactionExtension from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient, BaseTransactionsClient -from stac_fastapi.types.stac import Item, ItemCollection +from stac_fastapi.types.stac import Collection, Item, ItemCollection class DummyCoreClient(BaseCoreClient): @@ -32,25 +32,32 @@ def item_collection(self, *args, **kwargs): class DummyTransactionsClient(BaseTransactionsClient): - """Defines a pattern for implementing the STAC transaction extension.""" + """Dummy client returning parts of the request, rather than proper STAC items.""" - def create_item(self, item: Union[Item, ItemCollection], *args, **kwargs): - return {"created": True, "type": item["type"]} + def create_item(self, item: Union[Item, ItemCollection], **kwargs): + return {"type": item["type"]} - def update_item(self, *args, **kwargs): - raise NotImplementedError + def update_item(self, collection_id: str, item_id: str, item: Item, **kwargs): + return { + "path_collection_id": collection_id, + "path_item_id": item_id, + "type": item["type"], + } - def delete_item(self, *args, **kwargs): - raise NotImplementedError + def delete_item(self, item_id: str, collection_id: str, **kwargs): + return { + "path_collection_id": collection_id, + "path_item_id": item_id, + } - def create_collection(self, *args, **kwargs): - raise NotImplementedError + def create_collection(self, collection: Collection, **kwargs): + return {"type": collection["type"]} - def update_collection(self, *args, **kwargs): - raise NotImplementedError + def update_collection(self, collection_id: str, collection: Collection, **kwargs): + return {"path_collection_id": collection_id, "type": collection["type"]} - def delete_collection(self, *args, **kwargs): - raise NotImplementedError + def delete_collection(self, collection_id: str, **kwargs): + return {"path_collection_id": collection_id} def test_create_item(client: TestClient, item: Item) -> None: @@ -69,6 +76,42 @@ def test_create_item_collection( assert response.json()["type"] == "FeatureCollection" +def test_update_item(client: TestClient, item: Item) -> None: + response = client.put( + "/collections/a-collection/items/an-item", content=json.dumps(item) + ) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["path_item_id"] == "an-item" + assert response.json()["type"] == "Feature" + + +def test_delete_item(client: TestClient) -> None: + response = client.delete("/collections/a-collection/items/an-item") + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["path_item_id"] == "an-item" + + +def test_create_collection(client: TestClient, collection: Collection) -> None: + response = client.post("/collections", content=json.dumps(collection)) + assert response.is_success, response.text + assert response.json()["type"] == "Collection" + + +def test_update_collection(client: TestClient, collection: Collection) -> None: + response = client.put("/collections/a-collection", content=json.dumps(collection)) + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + assert response.json()["type"] == "Collection" + + +def test_delete_collection(client: TestClient, collection: Collection) -> None: + response = client.delete("/collections/a-collection") + assert response.is_success, response.text + assert response.json()["path_collection_id"] == "a-collection" + + @pytest.fixture def client( core_client: DummyCoreClient, transactions_client: DummyTransactionsClient @@ -119,3 +162,19 @@ def item() -> Item: "assets": {}, "collection": "test_collection", } + + +@pytest.fixture +def collection() -> Collection: + return { + "type": "Collection", + "stac_version": "1.0.0", + "stac_extensions": [], + "id": "test_collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": { + "interval": [["2000-01-01T00:00:00Z", "2024-01-01T00:00:00Z"]] + }, + }, + } diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 77078ace3..2c18649fa 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -109,10 +109,10 @@ def update_collection( ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. - Called with `PUT /collections/{collection_id}`. It is expected that this item - already exists. The update should do a diff against the saved collection and - perform any necessary updates. Partial updates are not supported by the - transactions extension. + Called with `PUT /collections/{collection_id}`. It is expected that this + collection already exists. The update should do a diff against the saved + collection and perform any necessary updates. Partial updates are not + supported by the transactions extension. Args: collection_id: id of the existing collection to be updated From f54227871c29cf15ae49b2ab96b28f357de60485 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 25 Apr 2024 00:02:44 +0800 Subject: [PATCH 16/57] update for v2.5.4 (#672) --- CHANGES.md | 5 ++++- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ca786b01a..ad626bc5c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.5.4] - 2024-04-24 + ### Fixed * Fix missing payload for the PUT `collection/{collection_id}` endpoint ([#665](https://github.com/stac-utils/stac-fastapi/issues/665)) @@ -332,7 +334,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.4]: [2.5.3]: [2.5.2]: [2.5.1]: diff --git a/VERSION b/VERSION index aedc15bb0..fe16b348d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.3 +2.5.4 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 1552b22f1..0082879b3 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.3" +__version__ = "2.5.4" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 1552b22f1..0082879b3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.3" +__version__ = "2.5.4" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 1552b22f1..0082879b3 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.3" +__version__ = "2.5.4" From 49de0ce58d604234b29c72aeabb06aeab36f6fef Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 25 Apr 2024 07:54:14 +0200 Subject: [PATCH 17/57] Patch/fix doc urls in landing (#673) * fix Documentation URL in landing page * update changelog --- CHANGES.md | 6 + stac_fastapi/api/tests/conftest.py | 118 ++++++++++++++++++ stac_fastapi/api/tests/test_app_prefix.py | 66 ++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 8 +- 4 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 stac_fastapi/api/tests/conftest.py create mode 100644 stac_fastapi/api/tests/test_app_prefix.py diff --git a/CHANGES.md b/CHANGES.md index ad626bc5c..abef58e2d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [2.5.5] - 2024-04-24 + +### Fixed + +* Fix `service-doc` and `service-desc` url in landing page when using router prefix + ## [2.5.4] - 2024-04-24 ### Fixed diff --git a/stac_fastapi/api/tests/conftest.py b/stac_fastapi/api/tests/conftest.py new file mode 100644 index 000000000..01db30ec2 --- /dev/null +++ b/stac_fastapi/api/tests/conftest.py @@ -0,0 +1,118 @@ +from datetime import datetime +from typing import List, Optional, Union + +import pytest +from stac_pydantic import Collection, Item +from stac_pydantic.api.utils import link_factory + +from stac_fastapi.types import core, stac +from stac_fastapi.types.core import NumType +from stac_fastapi.types.search import BaseSearchPostRequest + +collection_links = link_factory.CollectionLinks("/", "test").create_links() +item_links = link_factory.ItemLinks("/", "test", "test").create_links() + + +@pytest.fixture +def _collection(): + return Collection( + id="test_collection", + title="Test Collection", + description="A test collection", + keywords=["test"], + license="proprietary", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2000-01-01T00:00:00Z", None]]}, + }, + links=collection_links, + ) + + +@pytest.fixture +def collection(_collection: Collection): + return _collection.json() + + +@pytest.fixture +def collection_dict(_collection: Collection): + return _collection.dict() + + +@pytest.fixture +def _item(): + return Item( + id="test_item", + type="Feature", + geometry={"type": "Point", "coordinates": [0, 0]}, + bbox=[-180, -90, 180, 90], + properties={"datetime": "2000-01-01T00:00:00Z"}, + links=item_links, + assets={}, + ) + + +@pytest.fixture +def item(_item: Item): + return _item.json() + + +@pytest.fixture +def item_dict(_item: Item): + return _item.dict() + + +@pytest.fixture +def TestCoreClient(collection_dict, item_dict): + class CoreClient(core.BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + 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 stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: + return stac.Item(**item_dict) + + def all_collections(self, **kwargs) -> stac.Collections: + return stac.Collections( + collections=[stac.Collection(**collection_dict)], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: + return stac.Collection(**collection_dict) + + 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: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + return CoreClient diff --git a/stac_fastapi/api/tests/test_app_prefix.py b/stac_fastapi/api/tests/test_app_prefix.py new file mode 100644 index 000000000..39f099c50 --- /dev/null +++ b/stac_fastapi/api/tests/test_app_prefix.py @@ -0,0 +1,66 @@ +import urllib +from typing import Optional + +import pytest +from fastapi import APIRouter +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.types.config import ApiSettings + + +def get_link(landing_page, rel_type, method: Optional[str] = None): + return next( + filter( + lambda link: link["rel"] == rel_type + and (not method or link.get("method") == method), + landing_page["links"], + ), + None, + ) + + +@pytest.mark.parametrize("prefix", ["", "/a_prefix"]) +def test_api_prefix(TestCoreClient, prefix): + api_settings = ApiSettings( + openapi_url=f"{prefix}/api", + docs_url=f"{prefix}/api.html", + ) + + api = StacApi( + settings=api_settings, + client=TestCoreClient(), + router=APIRouter(prefix=prefix), + ) + + with TestClient(api.app, base_url="http://stac.io") as client: + landing = client.get(f"{prefix}/") + assert landing.status_code == 200, landing.json() + + service_doc = client.get(f"{prefix}/api.html") + assert service_doc.status_code == 200, service_doc.text + + service_desc = client.get(f"{prefix}/api") + assert service_desc.status_code == 200, service_desc.json() + + conformance = client.get(f"{prefix}/conformance") + assert conformance.status_code == 200, conformance.json() + + link_tests = [ + ("root", "application/json", "/"), + ("conformance", "application/json", "/conformance"), + ("service-doc", "text/html", "/api.html"), + ("service-desc", "application/vnd.oai.openapi+json;version=3.0", "/api"), + ] + + for rel_type, expected_media_type, expected_path in link_tests: + link = get_link(landing.json(), rel_type) + + assert link is not None, f"Missing {rel_type} link in landing page" + assert link.get("type") == expected_media_type + + link_path = urllib.parse.urlsplit(link.get("href")).path + assert link_path == prefix + expected_path + + resp = client.get(prefix + expected_path) + assert resp.status_code == 200 diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 2c18649fa..c6227a573 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -404,9 +404,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: "rel": "service-desc", "type": "application/vnd.oai.openapi+json;version=3.0", "title": "OpenAPI service description", - "href": urljoin( - str(request.base_url), request.app.openapi_url.lstrip("/") - ), + "href": str(request.url_for("openapi")), } ) @@ -416,9 +414,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: "rel": "service-doc", "type": "text/html", "title": "OpenAPI service documentation", - "href": urljoin( - str(request.base_url), request.app.docs_url.lstrip("/") - ), + "href": str(request.url_for("swagger_ui_html")), } ) From 24fd2588975d75ddc7bb515361415b54b965d7da Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 25 Apr 2024 14:05:29 +0800 Subject: [PATCH 18/57] v2.5.5 (#674) --- CHANGES.md | 3 ++- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index abef58e2d..77a7f2c1b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -340,7 +340,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.5]: [2.5.4]: [2.5.3]: [2.5.2]: diff --git a/VERSION b/VERSION index fe16b348d..0cadbc1e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.4 +2.5.5 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 0082879b3..f7ec15b04 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.4" +__version__ = "2.5.5" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 0082879b3..f7ec15b04 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.4" +__version__ = "2.5.5" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 0082879b3..f7ec15b04 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.4" +__version__ = "2.5.5" From 3e5fc1c74112e842bbac20de8f70ed71f01131be Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 25 Apr 2024 13:05:15 +0200 Subject: [PATCH 19/57] fix AsyncBaseCoreClient urls (#675) --- CHANGES.md | 6 ++ stac_fastapi/api/tests/conftest.py | 58 ++++++++++++++ stac_fastapi/api/tests/test_app_prefix.py | 80 +++++++++++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 4 +- 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 77a7f2c1b..c0f768671 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [2.5.5.post1] - 2024-04-25 + +### Fixed + +* Fix `service-doc` and `service-desc` url in landing page when using router prefix for `AsyncBaseCoreClient` + ## [2.5.5] - 2024-04-24 ### Fixed diff --git a/stac_fastapi/api/tests/conftest.py b/stac_fastapi/api/tests/conftest.py index 01db30ec2..ed8c66d4d 100644 --- a/stac_fastapi/api/tests/conftest.py +++ b/stac_fastapi/api/tests/conftest.py @@ -116,3 +116,61 @@ def item_collection( ) return CoreClient + + +@pytest.fixture +def AsyncTestCoreClient(collection_dict, item_dict): + class AsyncCoreClient(core.AsyncBaseCoreClient): + async def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + async 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 stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + async def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> stac.Item: + return stac.Item(**item_dict) + + async def all_collections(self, **kwargs) -> stac.Collections: + return stac.Collections( + collections=[stac.Collection(**collection_dict)], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + async def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: + return stac.Collection(**collection_dict) + + async 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: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + return AsyncCoreClient diff --git a/stac_fastapi/api/tests/test_app_prefix.py b/stac_fastapi/api/tests/test_app_prefix.py index 39f099c50..0e76e4560 100644 --- a/stac_fastapi/api/tests/test_app_prefix.py +++ b/stac_fastapi/api/tests/test_app_prefix.py @@ -46,9 +46,89 @@ def test_api_prefix(TestCoreClient, prefix): conformance = client.get(f"{prefix}/conformance") assert conformance.status_code == 200, conformance.json() + # NOTE: The collections/collection/items/item links do not have the prefix + # because they are created in the fixtures + collections = client.get(f"{prefix}/collections") + assert collections.status_code == 200, collections.json() + collection_id = collections.json()["collections"][0]["id"] + print(collections.json()["links"]) + collection = client.get(f"{prefix}/collections/{collection_id}") + assert collection.status_code == 200, collection.json() + + items = client.get(f"{prefix}/collections/{collection_id}/items") + assert items.status_code == 200, items.json() + + item_id = items.json()["features"][0]["id"] + item = client.get(f"{prefix}/collections/{collection_id}/items/{item_id}") + assert item.status_code == 200, item.json() + + link_tests = [ + ("root", "application/json", "/"), + ("conformance", "application/json", "/conformance"), + ("data", "application/json", "/collections"), + ("search", "application/geo+json", "/search"), + ("service-doc", "text/html", "/api.html"), + ("service-desc", "application/vnd.oai.openapi+json;version=3.0", "/api"), + ] + + for rel_type, expected_media_type, expected_path in link_tests: + link = get_link(landing.json(), rel_type) + + assert link is not None, f"Missing {rel_type} link in landing page" + assert link.get("type") == expected_media_type + + link_path = urllib.parse.urlsplit(link.get("href")).path + assert link_path == prefix + expected_path + + resp = client.get(prefix + expected_path) + assert resp.status_code == 200 + + +@pytest.mark.parametrize("prefix", ["", "/a_prefix"]) +def test_async_api_prefix(AsyncTestCoreClient, prefix): + api_settings = ApiSettings( + openapi_url=f"{prefix}/api", + docs_url=f"{prefix}/api.html", + ) + + api = StacApi( + settings=api_settings, + client=AsyncTestCoreClient(), + router=APIRouter(prefix=prefix), + ) + + with TestClient(api.app, base_url="http://stac.io") as client: + landing = client.get(f"{prefix}/") + assert landing.status_code == 200, landing.json() + + service_doc = client.get(f"{prefix}/api.html") + assert service_doc.status_code == 200, service_doc.text + + service_desc = client.get(f"{prefix}/api") + assert service_desc.status_code == 200, service_desc.json() + + conformance = client.get(f"{prefix}/conformance") + assert conformance.status_code == 200, conformance.json() + + collections = client.get(f"{prefix}/collections") + assert collections.status_code == 200, collections.json() + collection_id = collections.json()["collections"][0]["id"] + + collection = client.get(f"{prefix}/collections/{collection_id}") + assert collection.status_code == 200, collection.json() + + items = client.get(f"{prefix}/collections/{collection_id}/items") + assert items.status_code == 200, items.json() + + item_id = items.json()["features"][0]["id"] + item = client.get(f"{prefix}/collections/{collection_id}/items/{item_id}") + assert item.status_code == 200, item.json() + link_tests = [ ("root", "application/json", "/"), ("conformance", "application/json", "/conformance"), + ("data", "application/json", "/collections"), + ("search", "application/geo+json", "/search"), ("service-doc", "text/html", "/api.html"), ("service-desc", "application/vnd.oai.openapi+json;version=3.0", "/api"), ] diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index c6227a573..05c3e1097 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -612,7 +612,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: "rel": "service-desc", "type": "application/vnd.oai.openapi+json;version=3.0", "title": "OpenAPI service description", - "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), + "href": str(request.url_for("openapi")), } ) @@ -622,7 +622,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: "rel": "service-doc", "type": "text/html", "title": "OpenAPI service documentation", - "href": urljoin(base_url, request.app.docs_url.lstrip("/")), + "href": str(request.url_for("swagger_ui_html")), } ) From 47704ad9f906fce02c999b4156ae3f08f7570721 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 25 Apr 2024 20:04:48 +0800 Subject: [PATCH 20/57] v2.5.5.post1 (#676) * 2.5.5.post1 * fix version link --- CHANGES.md | 7 ++++--- VERSION | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c0f768671..2cde04885 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,13 +6,13 @@ ### Fixed -* Fix `service-doc` and `service-desc` url in landing page when using router prefix for `AsyncBaseCoreClient` +* Fix `service-doc` and `service-desc` url in landing page when using router prefix for `AsyncBaseCoreClient`. ([#675](https://github.com/stac-utils/stac-fastapi/pull/675)) ## [2.5.5] - 2024-04-24 ### Fixed -* Fix `service-doc` and `service-desc` url in landing page when using router prefix +* Fix `service-doc` and `service-desc` url in landing page when using router prefix. ([#673](https://github.com/stac-utils/stac-fastapi/pull/673)) ## [2.5.4] - 2024-04-24 @@ -346,7 +346,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[2.5.5.post1]: [2.5.5]: [2.5.4]: [2.5.3]: diff --git a/VERSION b/VERSION index 0cadbc1e3..0ee10b8e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.5 +2.5.5.post1 diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index f7ec15b04..017c068d4 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5" +__version__ = "2.5.5.post1" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index f7ec15b04..017c068d4 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5" +__version__ = "2.5.5.post1" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index f7ec15b04..017c068d4 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5" +__version__ = "2.5.5.post1" From 1299beac429242362aab9417ae20f9d18760bd8e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 25 Apr 2024 14:47:04 +0200 Subject: [PATCH 21/57] remove print (#677) --- stac_fastapi/api/tests/test_app_prefix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/api/tests/test_app_prefix.py b/stac_fastapi/api/tests/test_app_prefix.py index 0e76e4560..5d604477c 100644 --- a/stac_fastapi/api/tests/test_app_prefix.py +++ b/stac_fastapi/api/tests/test_app_prefix.py @@ -51,7 +51,7 @@ def test_api_prefix(TestCoreClient, prefix): collections = client.get(f"{prefix}/collections") assert collections.status_code == 200, collections.json() collection_id = collections.json()["collections"][0]["id"] - print(collections.json()["links"]) + collection = client.get(f"{prefix}/collections/{collection_id}") assert collection.status_code == 200, collection.json() From 63cac39108962b43b224e76ccf9c01c6fa85f2a0 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 26 Apr 2024 03:18:19 -0400 Subject: [PATCH 22/57] update to pydantic 2 (#625) * update to pydantic 2 * update changelog * typo * add CI for Python 3.12 * drop support for python 3.8 * update python version for docs * update python for docs docker container * update python version in dockerfile * handle post requests * test wrapper * pass through StacBaseModel * keep py38 * change install order * lint * revert back to >=3.8 in setup.py * add switch to use either TypeDict or StacPydantic Response * lint and format with ruff * remove comment * update change log * use Optional not | None * use Optional not | None * update dependencies * hard code versions and address other comments * remove response_model module, update openapi schema * add responses to transactions * do not wrap response into response_class * fix tests * update changelog, remove redundant variable * lint bench * reorder installs * do not push benchmark if not in stac-utils/stac-fastapi repo * Add text about response validation to readme. * fix warning * remove versions * fix * Update README.md * update changelog --------- Co-authored-by: vincentsarago Co-authored-by: Jonathan Healy --- .github/workflows/cicd.yaml | 1 + .github/workflows/deploy_mkdocs.yml | 4 +- .gitignore | 1 + .pre-commit-config.yaml | 9 +- CHANGES.md | 10 + Dockerfile | 2 +- Dockerfile.docs | 2 +- README.md | 12 ++ pyproject.toml | 11 +- stac_fastapi/api/setup.py | 3 - stac_fastapi/api/stac_fastapi/api/app.py | 119 ++++++++--- stac_fastapi/api/stac_fastapi/api/config.py | 1 + stac_fastapi/api/stac_fastapi/api/errors.py | 3 +- .../api/stac_fastapi/api/middleware.py | 1 + stac_fastapi/api/stac_fastapi/api/models.py | 47 ++--- stac_fastapi/api/stac_fastapi/api/openapi.py | 5 +- stac_fastapi/api/tests/benchmarks.py | 4 +- stac_fastapi/api/tests/conftest.py | 12 +- stac_fastapi/api/tests/test_api.py | 64 ++++-- stac_fastapi/api/tests/test_app.py | 188 +++++++++++++++++ stac_fastapi/api/tests/test_models.py | 101 +++++++++ stac_fastapi/extensions/setup.py | 4 +- .../stac_fastapi/extensions/core/__init__.py | 1 + .../extensions/core/fields/__init__.py | 1 - .../extensions/core/fields/fields.py | 1 + .../extensions/core/filter/__init__.py | 1 - .../extensions/core/query/query.py | 1 + .../extensions/core/sort/request.py | 2 +- .../stac_fastapi/extensions/core/sort/sort.py | 1 + .../extensions/core/transaction.py | 75 +++++-- .../extensions/third_party/__init__.py | 1 + .../third_party/bulk_transactions.py | 5 +- .../extensions/tests/test_transaction.py | 22 +- stac_fastapi/types/setup.py | 8 +- .../types/stac_fastapi/types/config.py | 9 +- .../types/stac_fastapi/types/conformance.py | 1 + stac_fastapi/types/stac_fastapi/types/core.py | 145 +++++++------ .../types/stac_fastapi/types/extension.py | 1 + .../types/stac_fastapi/types/requests.py | 4 +- .../types/stac_fastapi/types/rfc3339.py | 1 + .../types/stac_fastapi/types/search.py | 192 ++---------------- stac_fastapi/types/stac_fastapi/types/stac.py | 29 +-- 42 files changed, 688 insertions(+), 417 deletions(-) create mode 100644 stac_fastapi/api/tests/test_app.py create mode 100644 stac_fastapi/api/tests/test_models.py diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 641be715d..3da9d9cdc 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -81,6 +81,7 @@ jobs: run: python -m pytest stac_fastapi/api/tests/benchmarks.py --benchmark-only --benchmark-columns 'min, max, mean, median' --benchmark-json output.json - name: Store and benchmark result + if: github.repository == 'stac-utils/stac-fastapi' uses: benchmark-action/github-action-benchmark@v1 with: name: STAC FastAPI Benchmarks diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 4715015e9..a3469aad8 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -20,10 +20,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.11 - name: Install dependencies run: | diff --git a/.gitignore b/.gitignore index 908694a3a..3b2a1fea8 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ docs/api/* # Virtualenv venv +.venv/ # IDE .vscode \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 193edc5c7..68c3b8567 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,7 @@ repos: - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.267" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.2.2" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black + - id: ruff-format diff --git a/CHANGES.md b/CHANGES.md index 2cde04885..b47b03aa3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,15 @@ ## [Unreleased] +## Changes + +* Update to pydantic v2 and stac_pydantic v3 ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Removed internal Search and Operator Types in favor of stac_pydantic Types ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Fix response model validation ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* Add Response Model to OpenAPI, even if model validation is turned off ([#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)) +* Replace Black with Ruff Format ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) + ## [2.5.5.post1] - 2024-04-25 ### Fixed @@ -48,6 +57,7 @@ * Add `/queryables` link to the landing page ([#587](https://github.com/stac-utils/stac-fastapi/pull/587)) - `id`, `title`, `description` and `api_version` fields can be customized via env variables * Add `DeprecationWarning` for the `ContextExtension` +* Add support for Python 3.12 ### Changed diff --git a/Dockerfile b/Dockerfile index 501de7f36..9b6817182 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim as base +FROM python:3.11-slim as base # Any python libraries that require system libraries to be installed will likely # need the following packages in order to build diff --git a/Dockerfile.docs b/Dockerfile.docs index caa0f7e9f..6c7f00843 100644 --- a/Dockerfile.docs +++ b/Dockerfile.docs @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.11-slim # build-essential is required to build a wheel for ciso8601 RUN apt update && apt install -y build-essential diff --git a/README.md b/README.md index 9a8ec78ed..02c155993 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,18 @@ Backends are hosted in their own repositories: `stac-fastapi` was initially developed by [arturo-ai](https://github.com/arturo-ai). + +## Response Model Validation + +A common question when using this package is how request and response types are validated? + +This package uses [`stac-pydantic`](https://github.com/stac-utils/stac-pydantic) to validate and document STAC objects. However, by default, validation of response types is turned off and the API will simply forward responses without validating them against the Pydantic model first. This decision was made with the assumption that responses usually come from a (typed) database and can be considered safe. Extra validation would only increase latency, in particular for large payloads. + +To turn on response validation, set `ENABLE_RESPONSE_MODELS` to `True`. Either as an environment variable or directly in the `ApiSettings`. + +With the introduction of Pydantic 2, the extra [time it takes to validate models became negatable](https://github.com/stac-utils/stac-fastapi/pull/625#issuecomment-2045824578). While `ENABLE_RESPONSE_MODELS` still defaults to `False` there should be no penalty for users to turn on this feature but users discretion is advised. + + ## Installation ```bash diff --git a/pyproject.toml b/pyproject.toml index 162a81b1e..ad2edbb00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ [tool.ruff] +target-version = "py38" # minimum supported version line-length = 90 + +[tool.ruff.lint] select = [ "C9", "D1", @@ -9,13 +12,13 @@ select = [ "W", ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "**/tests/**/*.py" = ["D1"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["stac_fastapi"] known-third-party = ["stac_pydantic", "fastapi"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] -[tool.black] -target-version = ["py38", "py39", "py310", "py311"] +[tool.ruff.format] +quote-style = "double" diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index a5bfd897e..af596adf0 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -6,9 +6,6 @@ desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", "brotli_asgi", "stac-fastapi.types", ] diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 7ad0c96f5..194b22a00 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -1,5 +1,6 @@ """Fastapi app creation.""" + from typing import Any, Dict, List, Optional, Tuple, Type, Union import attr @@ -7,10 +8,10 @@ from fastapi import APIRouter, FastAPI from fastapi.openapi.utils import get_openapi from fastapi.params import Depends -from stac_pydantic import Collection, Item, ItemCollection -from stac_pydantic.api import ConformanceClasses, LandingPage +from stac_pydantic import api from stac_pydantic.api.collections import Collections -from stac_pydantic.version import STAC_VERSION +from stac_pydantic.api.version import STAC_API_VERSION +from stac_pydantic.shared import MimeTypes from starlette.responses import JSONResponse, Response from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers @@ -94,7 +95,7 @@ class StacApi: lambda self: self.settings.stac_fastapi_version, takes_self=True ) ) - stac_version: str = attr.ib(default=STAC_VERSION) + stac_version: str = attr.ib(default=STAC_API_VERSION) description: str = attr.ib( default=attr.Factory( lambda self: self.settings.stac_fastapi_description, takes_self=True @@ -138,9 +139,17 @@ def register_landing_page(self): self.router.add_api_route( name="Landing Page", path="/", - response_model=LandingPage - if self.settings.enable_response_models - else None, + response_model=( + api.LandingPage if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": api.LandingPage, + }, + }, response_class=self.response_class, response_model_exclude_unset=False, response_model_exclude_none=True, @@ -157,9 +166,17 @@ def register_conformance_classes(self): self.router.add_api_route( name="Conformance Classes", path="/conformance", - response_model=ConformanceClasses - if self.settings.enable_response_models - else None, + response_model=( + api.ConformanceClasses if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": api.ConformanceClasses, + }, + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -176,7 +193,15 @@ def register_get_item(self): self.router.add_api_route( name="Get Item", path="/collections/{collection_id}/items/{item_id}", - response_model=Item if self.settings.enable_response_models else None, + response_model=api.Item if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": api.Item, + }, + }, response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -194,9 +219,19 @@ def register_post_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (api.ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": api.ItemCollection, + }, + }, response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -216,9 +251,19 @@ def register_get_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (api.ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": api.ItemCollection, + }, + }, response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -237,9 +282,17 @@ def register_get_collections(self): self.router.add_api_route( name="Get Collections", path="/collections", - response_model=Collections - if self.settings.enable_response_models - else None, + response_model=( + Collections if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collections, + }, + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -256,7 +309,17 @@ def register_get_collection(self): self.router.add_api_route( name="Get Collection", path="/collections/{collection_id}", - response_model=Collection if self.settings.enable_response_models else None, + response_model=api.Collection + if self.settings.enable_response_models + else None, + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": api.Collection, + }, + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -283,9 +346,17 @@ def register_get_item_collection(self): self.router.add_api_route( name="Get ItemCollection", path="/collections/{collection_id}/items", - response_model=ItemCollection - if self.settings.enable_response_models - else None, + response_model=( + api.ItemCollection if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": api.ItemCollection, + }, + }, response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index e6e4d882a..3918421ff 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -1,4 +1,5 @@ """Application settings.""" + import enum diff --git a/stac_fastapi/api/stac_fastapi/api/errors.py b/stac_fastapi/api/stac_fastapi/api/errors.py index 3f052bd31..6d90ba63a 100644 --- a/stac_fastapi/api/stac_fastapi/api/errors.py +++ b/stac_fastapi/api/stac_fastapi/api/errors.py @@ -4,7 +4,7 @@ from typing import Callable, Dict, Type, TypedDict from fastapi import FastAPI -from fastapi.exceptions import RequestValidationError +from fastapi.exceptions import RequestValidationError, ResponseValidationError from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse @@ -27,6 +27,7 @@ DatabaseError: status.HTTP_424_FAILED_DEPENDENCY, Exception: status.HTTP_500_INTERNAL_SERVER_ERROR, InvalidQueryParameter: status.HTTP_400_BAD_REQUEST, + ResponseValidationError: status.HTTP_500_INTERNAL_SERVER_ERROR, } diff --git a/stac_fastapi/api/stac_fastapi/api/middleware.py b/stac_fastapi/api/stac_fastapi/api/middleware.py index 3ed67d6c9..2ba3ef570 100644 --- a/stac_fastapi/api/stac_fastapi/api/middleware.py +++ b/stac_fastapi/api/stac_fastapi/api/middleware.py @@ -1,4 +1,5 @@ """Api middleware.""" + import re import typing from http.client import HTTP_PORT, HTTPS_PORT diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 0721413a9..2716fe7fb 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,12 +1,11 @@ """Api request/response models.""" import importlib.util -from typing import Optional, Type, Union +from typing import List, Optional, Type, Union import attr -from fastapi import Body, Path +from fastapi import Path from pydantic import BaseModel, create_model -from pydantic.fields import UndefinedType from stac_pydantic.shared import BBox from stac_fastapi.types.extension import ApiExtension @@ -23,8 +22,8 @@ def create_request_model( model_name="SearchGetRequest", base_model: Union[Type[BaseModel], APIRequest] = BaseSearchGetRequest, - extensions: Optional[ApiExtension] = None, - mixins: Optional[Union[BaseModel, APIRequest]] = None, + extensions: Optional[List[ApiExtension]] = None, + mixins: Optional[Union[List[BaseModel], List[APIRequest]]] = None, request_type: Optional[str] = "GET", ) -> Union[Type[BaseModel], APIRequest]: """Create a pydantic model for validating request bodies.""" @@ -47,40 +46,19 @@ def create_request_model( # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): for model in models: - for k, v in model.__fields__.items(): - field_info = v.field_info - body = Body( - None - if isinstance(field_info.default, UndefinedType) - else field_info.default, - default_factory=field_info.default_factory, - alias=field_info.alias, - alias_priority=field_info.alias_priority, - title=field_info.title, - description=field_info.description, - const=field_info.const, - gt=field_info.gt, - ge=field_info.ge, - lt=field_info.lt, - le=field_info.le, - multiple_of=field_info.multiple_of, - min_items=field_info.min_items, - max_items=field_info.max_items, - min_length=field_info.min_length, - max_length=field_info.max_length, - regex=field_info.regex, - extra=field_info.extra, - ) - fields[k] = (v.outer_type_, body) + for k, field_info in model.model_fields.items(): + fields[k] = (field_info.annotation, field_info) return create_model(model_name, **fields, __base__=base_model) raise TypeError("Mixed Request Model types. Check extension request types.") def create_get_request_model( - extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest -): + extensions: Optional[List[ApiExtension]], + base_model: BaseSearchGetRequest = BaseSearchGetRequest, +) -> APIRequest: """Wrap create_request_model to create the GET request model.""" + return create_request_model( "SearchGetRequest", base_model=base_model, @@ -90,8 +68,9 @@ def create_get_request_model( def create_post_request_model( - extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest -): + extensions: Optional[List[ApiExtension]], + base_model: BaseSearchPostRequest = BaseSearchPostRequest, +) -> Type[BaseModel]: """Wrap create_request_model to create the POST request model.""" return create_request_model( "SearchPostRequest", diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index a38a70bae..ab90ce425 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -1,4 +1,5 @@ """openapi.""" + import warnings from fastapi import FastAPI @@ -43,9 +44,7 @@ async def patched_openapi_endpoint(req: Request) -> Response: # Get the response from the old endpoint function response: JSONResponse = await old_endpoint(req) # Update the content type header in place - response.headers[ - "content-type" - ] = "application/vnd.oai.openapi+json;version=3.0" + response.headers["content-type"] = "application/vnd.oai.openapi+json;version=3.0" # Return the updated response return response diff --git a/stac_fastapi/api/tests/benchmarks.py b/stac_fastapi/api/tests/benchmarks.py index ad73d2424..95e1c532a 100644 --- a/stac_fastapi/api/tests/benchmarks.py +++ b/stac_fastapi/api/tests/benchmarks.py @@ -160,9 +160,7 @@ def f(): benchmark.group = "Collection With Model validation" if validate else "Collection" benchmark.name = "Collection With Model validation" if validate else "Collection" - benchmark.fullname = ( - "Collection With Model validation" if validate else "Collection" - ) + benchmark.fullname = "Collection With Model validation" if validate else "Collection" response = benchmark(f) assert response.status_code == 200 diff --git a/stac_fastapi/api/tests/conftest.py b/stac_fastapi/api/tests/conftest.py index ed8c66d4d..1b89f07cd 100644 --- a/stac_fastapi/api/tests/conftest.py +++ b/stac_fastapi/api/tests/conftest.py @@ -31,12 +31,12 @@ def _collection(): @pytest.fixture def collection(_collection: Collection): - return _collection.json() + return _collection.model_dump_json() @pytest.fixture def collection_dict(_collection: Collection): - return _collection.dict() + return _collection.model_dump(mode="json") @pytest.fixture @@ -54,12 +54,12 @@ def _item(): @pytest.fixture def item(_item: Item): - return _item.json() + return _item.model_dump_json() @pytest.fixture def item_dict(_item: Item): - return _item.dict() + return _item.model_dump(mode="json") @pytest.fixture @@ -142,9 +142,7 @@ async def get_search( type="FeatureCollection", features=[stac.Item(**item_dict)] ) - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> stac.Item: + async def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: return stac.Item(**item_dict) async def all_collections(self, **kwargs) -> stac.Collections: diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 91b50371e..f2d51f1db 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -41,11 +41,11 @@ def _assert_dependency_applied(api, routes): method=route["method"].lower(), url=path, auth=("bob", "dobbs"), - content='{"dummy": "payload"}', + content=route["payload"], headers={"content-type": "application/json"}, ) assert ( - response.status_code == 200 + 200 <= response.status_code < 300 ), "Authenticated requests should be accepted" assert response.json() == "dummy response" @@ -58,27 +58,59 @@ def test_openapi_content_type(self): == "application/vnd.oai.openapi+json;version=3.0" ) - def test_build_api_with_route_dependencies(self): + def test_build_api_with_route_dependencies(self, collection, item): routes = [ - {"path": "/collections", "method": "POST"}, - {"path": "/collections/{collectionId}", "method": "PUT"}, - {"path": "/collections/{collectionId}", "method": "DELETE"}, - {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, + {"path": "/collections", "method": "POST", "payload": collection}, + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""}, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, ] dependencies = [Depends(must_be_bob)] api = self._build_api(route_dependencies=[(routes, dependencies)]) self._assert_dependency_applied(api, routes) - def test_add_route_dependencies_after_building_api(self): + def test_add_route_dependencies_after_building_api(self, collection, item): routes = [ - {"path": "/collections", "method": "POST"}, - {"path": "/collections/{collectionId}", "method": "PUT"}, - {"path": "/collections/{collectionId}", "method": "DELETE"}, - {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, + {"path": "/collections", "method": "POST", "payload": collection}, + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""}, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, ] api = self._build_api() api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py new file mode 100644 index 000000000..9b4e0e828 --- /dev/null +++ b/stac_fastapi/api/tests/test_app.py @@ -0,0 +1,188 @@ +from datetime import datetime +from typing import List, Optional, Union + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError +from stac_pydantic import api + +from stac_fastapi.api import app +from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.extensions.core.filter.filter import 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.search import BaseSearchPostRequest + + +def test_client_response_type(TestCoreClient): + """Test all GET endpoints. Verify that responses are valid STAC items.""" + + test_app = app.StacApi( + settings=ApiSettings(), + client=TestCoreClient(), + ) + + with TestClient(test_app.app) as client: + landing = client.get("/") + collection = client.get("/collections/test") + collections = client.get("/collections") + item = client.get("/collections/test/items/test") + item_collection = client.get( + "/collections/test/items", + params={"limit": 10}, + ) + get_search = client.get( + "/search", + params={ + "collections": ["test"], + }, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + }, + ) + + assert landing.status_code == 200, landing.text + api.LandingPage(**landing.json()) + + assert collection.status_code == 200, collection.text + api.Collection(**collection.json()) + + assert collections.status_code == 200, collections.text + api.collections.Collections(**collections.json()) + + assert item.status_code == 200, item.text + api.Item(**item.json()) + + assert item_collection.status_code == 200, item_collection.text + api.ItemCollection(**item_collection.json()) + + assert get_search.status_code == 200, get_search.text + api.ItemCollection(**get_search.json()) + + assert post_search.status_code == 200, post_search.text + api.ItemCollection(**post_search.json()) + + +@pytest.mark.parametrize("validate", [True, False]) +def test_client_invalid_response_type(validate, TestCoreClient, item_dict): + """Check if the build in response validation switch works.""" + + class InValidResponseClient(TestCoreClient): + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: + item_dict.pop("bbox") + item_dict.pop("geometry") + return stac.Item(**item_dict) + + test_app = app.StacApi( + settings=ApiSettings(enable_response_models=validate), + client=InValidResponseClient(), + ) + + with TestClient(test_app.app) as client: + item = client.get("/collections/test/items/test") + + # Even if API validation passes, we should receive an invalid item + if item.status_code == 200: + with pytest.raises(ValidationError): + api.Item(**item.json()) + + # If internal validation is on, we should expect an internal error + if validate: + assert item.status_code == 500, item.text + else: + assert item.status_code == 200, item.text + + +def test_client_openapi(TestCoreClient): + """Test if response models are all documented with OpenAPI.""" + + test_app = app.StacApi( + settings=ApiSettings(), + client=TestCoreClient(), + ) + test_app.app.openapi() + components = ["LandingPage", "Collection", "Collections", "Item", "ItemCollection"] + for component in components: + assert component in test_app.app.openapi_schema["components"]["schemas"] + + +def test_filter_extension(TestCoreClient, item_dict): + """Test if Filter Parameters are passed correctly.""" + + class FilterClient(TestCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + search_request.collections = ["test"] + search_request.filter = {} + search_request.filter_crs = "EPSG:4326" + search_request.filter_lang = "cql2-text" + + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + 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, + filter: Optional[str] = None, + filter_crs: Optional[str] = None, + filter_lang: Optional[str] = None, + **kwargs, + ) -> stac.ItemCollection: + # Check if all filter parameters are passed correctly + + assert filter == "TEST" + + # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638 + # hyphen alias for filter_crs and filter_lang are currently not working + # Query parameters `filter-crs` and `filter-lang` + # should be recognized by the API + # They are present in the `request.query_params` but not in the `kwargs` + + # assert filter_crs == "EPSG:4326" + # assert filter_lang == "cql2-text" + + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] + ) + + post_request_model = create_post_request_model([FilterExtension()]) + + test_app = app.StacApi( + settings=ApiSettings(), + client=FilterClient(post_request_model=post_request_model), + search_get_request_model=create_get_request_model([FilterExtension()]), + search_post_request_model=post_request_model, + ) + + with TestClient(test_app.app) as client: + get_search = client.get( + "/search", + params={ + "filter": "TEST", + "filter-crs": "EPSG:4326", + "filter-lang": "cql2-text", + }, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + "filter": {}, + "filter-crs": "EPSG:4326", + "filter-lang": "cql2-text", + }, + ) + + assert get_search.status_code == 200, get_search.text + assert post_search.status_code == 200, post_search.text diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py new file mode 100644 index 000000000..cbff0f53d --- /dev/null +++ b/stac_fastapi/api/tests/test_models.py @@ -0,0 +1,101 @@ +import json + +import pytest +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.types.search import BaseSearchGetRequest, BaseSearchPostRequest + + +def test_create_get_request_model(): + extensions = [FilterExtension()] + request_model = create_get_request_model(extensions, BaseSearchGetRequest) + + model = request_model( + collections="test1,test2", + ids="test1,test2", + bbox="0,0,1,1", + intersects=json.dumps( + { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + } + ), + 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"}, + ) + + assert model.collections == ["test1", "test2"] + # assert model.filter_crs == "epsg:4326" + + +@pytest.mark.parametrize( + "filter,passes", + [(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) + + if not passes: + with pytest.raises(ValidationError): + model = request_model(filter=filter) + else: + model = request_model( + collections=["test1", "test2"], + ids=["test1", "test2"], + bbox=[0, 0, 1, 1], + datetime="2020-01-01T00:00:00Z", + limit=10, + filter=filter, + **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"}, + ) + + assert model.collections == ["test1", "test2"] + assert model.filter_crs == "epsg:4326" + assert model.filter == filter + + +@pytest.mark.parametrize( + "sortby,passes", + [ + (None, True), + ( + [ + {"field": "test", "direction": "asc"}, + {"field": "test2", "direction": "desc"}, + ], + True, + ), + ({"field": "test", "direction": "desc"}, False), + ("test", False), + ], +) +def test_create_post_request_model_nested_fields(sortby, passes): + extensions = [SortExtension()] + request_model = create_post_request_model(extensions, BaseSearchPostRequest) + + if not passes: + with pytest.raises(ValidationError): + model = request_model(sortby=sortby) + else: + model = request_model( + collections=["test1", "test2"], + ids=["test1", "test2"], + bbox=[0, 0, 1, 1], + datetime="2020-01-01T00:00:00Z", + limit=10, + sortby=sortby, + ) + + assert model.collections == ["test1", "test2"] + if model.sortby is None: + assert sortby is None + else: + assert model.model_dump(mode="json")["sortby"] == sortby diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index af564931b..39bc59b3f 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -1,14 +1,12 @@ """stac_fastapi: extensions module.""" + from setuptools import find_namespace_packages, setup with open("README.md") as f: desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", "stac-fastapi.types", "stac-fastapi.api", ] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 96317fe4a..74f15ed0a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,4 +1,5 @@ """stac_api.extensions.core module.""" + from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py index b9a246b63..087d01b7a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py @@ -1,6 +1,5 @@ """Fields extension module.""" - from .fields import FieldsExtension __all__ = ["FieldsExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py index df4cd44de..25b6fe252 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py @@ -1,4 +1,5 @@ """Fields extension.""" + from typing import List, Optional, Set import attr diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py index 78256bfd2..256f3e06e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py @@ -1,6 +1,5 @@ """Filter extension module.""" - from .filter import FilterExtension __all__ = ["FilterExtension"] 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 3e85b406d..dcb162060 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py @@ -1,4 +1,5 @@ """Query extension.""" + from typing import List, Optional import attr 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 c19f40dba..377067ff9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -20,4 +20,4 @@ class SortExtensionGetRequest(APIRequest): class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] + sortby: Optional[List[PostSortModel]] = None diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py index 5dd96cfa6..4b27d8d0e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py @@ -1,4 +1,5 @@ """Sort extension.""" + from typing import List, Optional import attr diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 86e1bfc52..818315e1a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -4,12 +4,13 @@ import attr from fastapi import APIRouter, Body, FastAPI -from stac_pydantic import Collection, Item +from stac_pydantic import Collection, Item, ItemCollection +from stac_pydantic.shared import MimeTypes from starlette.responses import JSONResponse, Response from stac_fastapi.api.models import CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint -from stac_fastapi.types import stac as stac_types +from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension @@ -19,23 +20,21 @@ class PostItem(CollectionUri): """Create Item.""" - item: Union[stac_types.Item, stac_types.ItemCollection] = attr.ib( - default=Body(None) - ) + item: Union[Item, ItemCollection] = attr.ib(default=Body(None)) @attr.s class PutItem(ItemUri): """Update Item.""" - item: stac_types.Item = attr.ib(default=Body(None)) + item: Item = attr.ib(default=Body(None)) @attr.s class PutCollection(CollectionUri): """Update Collection.""" - collection: stac_types.Collection = attr.ib(default=Body(None)) + collection: stac.Collection = attr.ib(default=Body(None)) @attr.s @@ -73,7 +72,16 @@ def register_create_item(self): self.router.add_api_route( name="Create Item", path="/collections/{collection_id}/items", + status_code=201, response_model=Item if self.settings.enable_response_models else None, + responses={ + 201: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": Item, + } + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -88,6 +96,14 @@ def register_update_item(self): name="Update Item", path="/collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": Item, + } + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -102,6 +118,14 @@ def register_delete_item(self): name="Delete Item", path="/collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + }, + "model": Item, + } + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -114,14 +138,21 @@ def register_create_collection(self): self.router.add_api_route( name="Create Collection", path="/collections", + status_code=201, response_model=Collection if self.settings.enable_response_models else None, + responses={ + 201: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collection, + } + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["POST"], - endpoint=create_async_endpoint( - self.client.create_collection, stac_types.Collection - ), + endpoint=create_async_endpoint(self.client.create_collection, Collection), ) def register_update_collection(self): @@ -130,13 +161,19 @@ def register_update_collection(self): name="Update Collection", path="/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collection, + } + }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["PUT"], - endpoint=create_async_endpoint( - self.client.update_collection, PutCollection - ), + endpoint=create_async_endpoint(self.client.update_collection, PutCollection), ) def register_delete_collection(self): @@ -145,13 +182,19 @@ def register_delete_collection(self): name="Delete Collection", path="/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + }, + "model": Collection, + } + }, response_class=self.response_class, 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/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index ab7349e60..d35c4c8f9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -1,4 +1,5 @@ """stac_api.extensions.third_party module.""" + from .bulk_transactions import BulkTransactionExtension __all__ = ("BulkTransactionExtension",) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py index 9fa96ff2b..d1faa5c0f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py @@ -1,4 +1,5 @@ """Bulk transactions extension.""" + import abc from enum import Enum from typing import Any, Dict, List, Optional, Union @@ -109,9 +110,7 @@ class BulkTransactionExtension(ApiExtension): } """ - client: Union[ - AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient - ] = attr.ib() + client: Union[AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient] = attr.ib() conformance_classes: List[str] = attr.ib(default=list()) schema_href: Optional[str] = attr.ib(default=None) diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index e6416eaea..d686d8f91 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -2,13 +2,15 @@ from typing import Iterator, Union import pytest +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi from stac_fastapi.extensions.core import TransactionExtension from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient, BaseTransactionsClient -from stac_fastapi.types.stac import Collection, Item, ItemCollection +from stac_fastapi.types.stac import Collection class DummyCoreClient(BaseCoreClient): @@ -34,14 +36,14 @@ def item_collection(self, *args, **kwargs): class DummyTransactionsClient(BaseTransactionsClient): """Dummy client returning parts of the request, rather than proper STAC items.""" - def create_item(self, item: Union[Item, ItemCollection], **kwargs): - return {"type": item["type"]} + def create_item(self, item: Union[Item, ItemCollection], *args, **kwargs): + return {"created": True, "type": item.type} def update_item(self, collection_id: str, item_id: str, item: Item, **kwargs): return { "path_collection_id": collection_id, "path_item_id": item_id, - "type": item["type"], + "type": item.type, } def delete_item(self, item_id: str, collection_id: str, **kwargs): @@ -51,7 +53,7 @@ def delete_item(self, item_id: str, collection_id: str, **kwargs): } def create_collection(self, collection: Collection, **kwargs): - return {"type": collection["type"]} + return {"type": collection.type} def update_collection(self, collection_id: str, collection: Collection, **kwargs): return {"path_collection_id": collection_id, "type": collection["type"]} @@ -157,7 +159,7 @@ def item() -> Item: "id": "test_item", "geometry": {"type": "Point", "coordinates": [-105, 40]}, "bbox": [-105, 40, -105, 40], - "properties": {}, + "properties": {"datetime": "2020-06-13T13:00:00Z"}, "links": [], "assets": {}, "collection": "test_collection", @@ -171,10 +173,12 @@ def collection() -> Collection: "stac_version": "1.0.0", "stac_extensions": [], "id": "test_collection", + "description": "A test collection", "extent": { "spatial": {"bbox": [[-180, -90, 180, 90]]}, - "temporal": { - "interval": [["2000-01-01T00:00:00Z", "2024-01-01T00:00:00Z"]] - }, + "temporal": {"interval": [["2000-01-01T00:00:00Z", "2024-01-01T00:00:00Z"]]}, }, + "links": [], + "assets": {}, + "license": "proprietary", } diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index c3905ede5..0b9448e39 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -6,10 +6,10 @@ desc = f.read() install_requires = [ - "fastapi>=0.73.0", - "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", + "fastapi>=0.100.0", + "attrs>=23.2.0", + "pydantic-settings>=2", + "stac_pydantic>=3", "pystac==1.*", "iso8601>=1.0.2,<2.2.0", ] diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 4b88c56a4..d692043cc 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,7 +1,8 @@ """stac_fastapi.types.config module.""" + from typing import Optional, Set -from pydantic import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class ApiSettings(BaseSettings): @@ -35,11 +36,7 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" - class Config: - """Model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" - - extra = "allow" - env_file = ".env" + model_config = SettingsConfigDict(env_file=".env", extra="allow") class Settings: diff --git a/stac_fastapi/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 13836aaf5..840584c1b 100644 --- a/stac_fastapi/types/stac_fastapi/types/conformance.py +++ b/stac_fastapi/types/stac_fastapi/types/conformance.py @@ -1,4 +1,5 @@ """Conformance Classes.""" + from enum import Enum diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 05c3e1097..d0dc029f0 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,24 +1,26 @@ """Base clients.""" + import abc from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin import attr from fastapi import Request +from geojson_pydantic.geometries import Geometry +from stac_pydantic import Collection, Item, ItemCollection +from stac_pydantic.api.version import STAC_API_VERSION from stac_pydantic.links import Relations from stac_pydantic.shared import BBox, MimeTypes -from stac_pydantic.version import STAC_VERSION from starlette.responses import Response -from stac_fastapi.types import stac as stac_types +from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.rfc3339 import DateTimeType from stac_fastapi.types.search import BaseSearchPostRequest -from stac_fastapi.types.stac import Conformance NumType = Union[float, int] StacType = Dict[str, Any] @@ -34,9 +36,9 @@ class BaseTransactionsClient(abc.ABC): def create_item( self, collection_id: str, - item: Union[stac_types.Item, stac_types.ItemCollection], + item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[stac_types.Item, Response, None]]: + ) -> Optional[Union[Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -52,8 +54,8 @@ def create_item( @abc.abstractmethod def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + self, collection_id: str, item_id: str, item: Item, **kwargs + ) -> Optional[Union[Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -73,7 +75,7 @@ def update_item( @abc.abstractmethod def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + ) -> Optional[Union[Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -89,8 +91,8 @@ def delete_item( @abc.abstractmethod def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -105,8 +107,8 @@ def create_collection( @abc.abstractmethod def update_collection( - self, collection_id: str, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection_id: str, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections/{collection_id}`. It is expected that this @@ -126,7 +128,7 @@ def update_collection( @abc.abstractmethod def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + ) -> Optional[Union[Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` @@ -148,9 +150,9 @@ class AsyncBaseTransactionsClient(abc.ABC): async def create_item( self, collection_id: str, - item: Union[stac_types.Item, stac_types.ItemCollection], + item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[stac_types.Item, Response, None]]: + ) -> Optional[Union[Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -166,8 +168,8 @@ async def create_item( @abc.abstractmethod async def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + self, collection_id: str, item_id: str, item: Item, **kwargs + ) -> Optional[Union[Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -186,7 +188,7 @@ async def update_item( @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + ) -> Optional[Union[Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -202,8 +204,8 @@ async def delete_item( @abc.abstractmethod async def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -218,8 +220,8 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection_id: str, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection_id: str, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections/{collection_id}`. It is expected that this item @@ -239,7 +241,7 @@ async def update_collection( @abc.abstractmethod async def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + ) -> Optional[Union[Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` @@ -257,7 +259,7 @@ async def delete_collection( class LandingPageMixin(abc.ABC): """Create a STAC landing page (GET /).""" - stac_version: str = attr.ib(default=STAC_VERSION) + stac_version: str = attr.ib(default=STAC_API_VERSION) landing_page_id: str = attr.ib(default=api_settings.stac_fastapi_landing_id) title: str = attr.ib(default=api_settings.stac_fastapi_title) description: str = attr.ib(default=api_settings.stac_fastapi_description) @@ -267,8 +269,8 @@ def _landing_page( base_url: str, conformance_classes: List[str], extension_schemas: List[str], - ) -> stac_types.LandingPage: - landing_page = stac_types.LandingPage( + ) -> stac.LandingPage: + landing_page = stac.LandingPage( type="Catalog", id=self.landing_page_id, title=self.title, @@ -278,35 +280,35 @@ def _landing_page( links=[ { "rel": Relations.self.value, - "type": MimeTypes.json, + "type": MimeTypes.json.value, "href": base_url, }, { "rel": Relations.root.value, - "type": MimeTypes.json, + "type": MimeTypes.json.value, "href": base_url, }, { - "rel": "data", - "type": MimeTypes.json, + "rel": Relations.data.value, + "type": MimeTypes.json.value, "href": urljoin(base_url, "collections"), }, { "rel": Relations.conformance.value, - "type": MimeTypes.json, + "type": MimeTypes.json.value, "title": "STAC/OGC conformance classes implemented by this server", "href": urljoin(base_url, "conformance"), }, { "rel": Relations.search.value, - "type": MimeTypes.geojson, + "type": MimeTypes.geojson.value, "title": "STAC search", "href": urljoin(base_url, "search"), "method": "GET", }, { "rel": Relations.search.value, - "type": MimeTypes.geojson, + "type": MimeTypes.geojson.value, "title": "STAC search", "href": urljoin(base_url, "search"), "method": "POST", @@ -314,6 +316,7 @@ def _landing_page( ], stac_extensions=extension_schemas, ) + return landing_page @@ -356,7 +359,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> stac_types.LandingPage: + def landing_page(self, **kwargs) -> stac.LandingPage: """Landing page. Called with `GET /`. @@ -366,6 +369,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: """ request: Request = kwargs["request"] base_url = get_base_url(request) + landing_page = self._landing_page( base_url=base_url, conformance_classes=self.conformance_classes(), @@ -388,6 +392,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add Collections links collections = self.all_collections(request=kwargs["request"]) + for collection in collections["collections"]: landing_page["links"].append( { @@ -401,8 +406,8 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add OpenAPI URL landing_page["links"].append( { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", + "rel": Relations.service_desc.value, + "type": MimeTypes.openapi.value, "title": "OpenAPI service description", "href": str(request.url_for("openapi")), } @@ -411,16 +416,16 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add human readable service-doc landing_page["links"].append( { - "rel": "service-doc", - "type": "text/html", + "rel": Relations.service_doc.value, + "type": MimeTypes.html.value, "title": "OpenAPI service documentation", "href": str(request.url_for("swagger_ui_html")), } ) - return landing_page + return stac.LandingPage(**landing_page) - def conformance(self, **kwargs) -> stac_types.Conformance: + def conformance(self, **kwargs) -> stac.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -428,12 +433,12 @@ def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return stac.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -452,15 +457,11 @@ def get_search( collections: Optional[List[str]] = None, ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, + intersects: Optional[Geometry] = None, datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -471,7 +472,7 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -486,7 +487,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Ite ... @abc.abstractmethod - def all_collections(self, **kwargs) -> stac_types.Collections: + def all_collections(self, **kwargs) -> stac.Collections: """Get all available collections. Called with `GET /collections`. @@ -497,7 +498,7 @@ def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -519,7 +520,7 @@ def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -564,7 +565,7 @@ def extension_is_enabled(self, extension: str) -> bool: """Check if an api extension is enabled.""" return any([type(ext).__name__ == extension for ext in self.extensions]) - async def landing_page(self, **kwargs) -> stac_types.LandingPage: + async def landing_page(self, **kwargs) -> stac.LandingPage: """Landing page. Called with `GET /`. @@ -574,6 +575,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: """ request: Request = kwargs["request"] base_url = get_base_url(request) + landing_page = self._landing_page( base_url=base_url, conformance_classes=self.conformance_classes(), @@ -596,6 +598,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add Collections links collections = await self.all_collections(request=kwargs["request"]) + for collection in collections["collections"]: landing_page["links"].append( { @@ -609,8 +612,8 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add OpenAPI URL landing_page["links"].append( { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", + "rel": Relations.service_desc.value, + "type": MimeTypes.openapi.value, "title": "OpenAPI service description", "href": str(request.url_for("openapi")), } @@ -619,16 +622,16 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add human readable service-doc landing_page["links"].append( { - "rel": "service-doc", - "type": "text/html", + "rel": Relations.service_doc.value, + "type": MimeTypes.html.value, "title": "OpenAPI service documentation", "href": str(request.url_for("swagger_ui_html")), } ) - return landing_page + return stac.LandingPage(**landing_page) - async def conformance(self, **kwargs) -> stac_types.Conformance: + async def conformance(self, **kwargs) -> stac.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -636,12 +639,12 @@ async def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return stac.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod async def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -660,15 +663,11 @@ async def get_search( collections: Optional[List[str]] = None, ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, + intersects: Optional[Geometry] = None, datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -679,9 +678,7 @@ async def get_search( ... @abc.abstractmethod - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> stac_types.Item: + async def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -696,7 +693,7 @@ async def get_item( ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> stac_types.Collections: + async def all_collections(self, **kwargs) -> stac.Collections: """Get all available collections. Called with `GET /collections`. @@ -707,9 +704,7 @@ async def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - async def get_collection( - self, collection_id: str, **kwargs - ) -> stac_types.Collection: + async def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -731,7 +726,7 @@ async def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> stac.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` diff --git a/stac_fastapi/types/stac_fastapi/types/extension.py b/stac_fastapi/types/stac_fastapi/types/extension.py index 732a907bf..55a4a123c 100644 --- a/stac_fastapi/types/stac_fastapi/types/extension.py +++ b/stac_fastapi/types/stac_fastapi/types/extension.py @@ -1,4 +1,5 @@ """Base api extension.""" + import abc from typing import List, Optional diff --git a/stac_fastapi/types/stac_fastapi/types/requests.py b/stac_fastapi/types/stac_fastapi/types/requests.py index c9be8b6f6..4d94736a7 100644 --- a/stac_fastapi/types/stac_fastapi/types/requests.py +++ b/stac_fastapi/types/stac_fastapi/types/requests.py @@ -9,6 +9,4 @@ def get_base_url(request: Request) -> str: if not app.state.router_prefix: return str(request.base_url) else: - return "{}{}/".format( - str(request.base_url), app.state.router_prefix.lstrip("/") - ) + return "{}{}/".format(str(request.base_url), app.state.router_prefix.lstrip("/")) diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 1277c998a..2f0a1f346 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -1,4 +1,5 @@ """rfc3339.""" + import re from datetime import datetime, timezone from typing import Optional, Tuple, Union diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index fb847349e..cf6647340 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -1,80 +1,26 @@ """stac_fastapi.types.search module. -# TODO: replace with stac-pydantic """ import abc -import operator -from datetime import datetime -from enum import auto -from types import DynamicClassAttribute -from typing import Any, Callable, Dict, Generator, List, Optional, Union +from typing import Dict, List, Optional, Union import attr -from geojson_pydantic.geometries import ( - GeometryCollection, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Point, - Polygon, - _GeometryBase, -) -from pydantic import BaseModel, ConstrainedInt, Field, validator -from pydantic.errors import NumberNotGtError -from pydantic.validators import int_validator +from pydantic import PositiveInt +from pydantic.functional_validators import AfterValidator +from stac_pydantic.api import Search from stac_pydantic.shared import BBox -from stac_pydantic.utils import AutoValueEnum +from typing_extensions import Annotated from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval -# Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 -NumType = Union[float, int] - - -class Limit(ConstrainedInt): - """An positive integer that maxes out at 10,000.""" - - ge: int = 1 - le: int = 10_000 - - @classmethod - def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: - """Yield the relevant validators.""" - yield int_validator - yield cls.validate - - @classmethod - def validate(cls, value: int) -> int: - """Validate the integer value.""" - if value < cls.ge: - raise NumberNotGtError(limit_value=cls.ge) - if value > cls.le: - return cls.le - return value - -class Operator(str, AutoValueEnum): - """Defines the set of operators supported by the API.""" - - eq = auto() - ne = auto() - lt = auto() - lte = auto() - gt = auto() - gte = auto() - - # TODO: These are defined in the spec but aren't currently implemented by the api - # startsWith = auto() - # endsWith = auto() - # contains = auto() - # in = auto() - - @DynamicClassAttribute - def operator(self) -> Callable[[Any, Any], bool]: - """Return python operator.""" - return getattr(operator, self._value_) +def crop(v: PositiveInt) -> PositiveInt: + """Crop value to 10,000.""" + limit = 10_000 + if v > limit: + v = limit + return v def str2list(x: str) -> Optional[List]: @@ -91,6 +37,11 @@ def str2bbox(x: str) -> Optional[BBox]: return t +# 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): """Generic API Request base class.""" @@ -113,110 +64,7 @@ class BaseSearchGetRequest(APIRequest): limit: Optional[int] = attr.ib(default=10) -class BaseSearchPostRequest(BaseModel): - """Search model. - - Replace base model in STAC-pydantic as it includes additional fields, not in the core - model. - https://github.com/radiantearth/stac-api-spec/tree/master/item-search#query-parameter-table - - PR to fix this: - https://github.com/stac-utils/stac-pydantic/pull/100 - """ - - collections: Optional[List[str]] - ids: Optional[List[str]] - bbox: Optional[BBox] - intersects: Optional[ - Union[ - Point, - MultiPoint, - LineString, - MultiLineString, - Polygon, - MultiPolygon, - GeometryCollection, - ] - ] - datetime: Optional[DateTimeType] - limit: Optional[Limit] = Field(default=10) - - @property - def start_date(self) -> Optional[datetime]: - """Extract the start date from the datetime string.""" - return self.datetime[0] if self.datetime else None - - @property - def end_date(self) -> Optional[datetime]: - """Extract the end date from the datetime string.""" - return self.datetime[1] if self.datetime else None - - @validator("intersects") - def validate_spatial(cls, v, values): - """Check bbox and intersects are not both supplied.""" - if v and values["bbox"]: - raise ValueError("intersects and bbox parameters are mutually exclusive") - return v - - @validator("bbox", pre=True) - def validate_bbox(cls, v: Union[str, BBox]) -> BBox: - """Check order of supplied bbox coordinates.""" - if v: - if type(v) == str: - v = str2bbox(v) - # Validate order - if len(v) == 4: - xmin, ymin, xmax, ymax = v - else: - xmin, ymin, min_elev, xmax, ymax, max_elev = 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 - - @validator("datetime", pre=True) - def validate_datetime(cls, v: Union[str, DateTimeType]) -> DateTimeType: - """Parse datetime.""" - if type(v) == str: - v = str_to_interval(v) - return v - - @property - def spatial_filter(self) -> Optional[_GeometryBase]: - """Return a geojson-pydantic object representing the spatial filter for the search - request. - - Check for both because the ``bbox`` and ``intersects`` parameters are - mutually exclusive. - """ - if self.bbox: - return Polygon( - coordinates=[ - [ - [self.bbox[0], self.bbox[3]], - [self.bbox[2], self.bbox[3]], - [self.bbox[2], self.bbox[1]], - [self.bbox[0], self.bbox[1]], - [self.bbox[0], self.bbox[3]], - ] - ] - ) - if self.intersects: - return self.intersects - return +class BaseSearchPostRequest(Search): + """Base arguments for POST Request.""" + + limit: Optional[Limit] = 10 diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index 51bb6e652..b9c93fd80 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,4 +1,5 @@ """STAC types.""" + import sys from typing import Any, Dict, List, Literal, Optional, Union @@ -6,9 +7,9 @@ # Avoids a Pydantic error: # TypeError: You should use `typing_extensions.TypedDict` instead of -# `typing.TypedDict` with Python < 3.9.2. Without it, there is no way to +# `typing.TypedDict` with Python < 3.12.0. Without it, there is no way to # differentiate required and optional fields when subclassed. -if sys.version_info < (3, 9, 2): +if sys.version_info < (3, 12, 0): from typing_extensions import TypedDict else: from typing import TypedDict @@ -16,35 +17,28 @@ NumType = Union[float, int] -class LandingPage(TypedDict, total=False): - """STAC Landing Page.""" +class Catalog(TypedDict, total=False): + """STAC Catalog.""" type: str stac_version: str stac_extensions: Optional[List[str]] id: str - title: str + title: Optional[str] description: str - conformsTo: List[str] links: List[Dict[str, Any]] -class Conformance(TypedDict): - """STAC Conformance Classes.""" +class LandingPage(Catalog, total=False): + """STAC Landing Page.""" conformsTo: List[str] -class Catalog(TypedDict, total=False): - """STAC Catalog.""" +class Conformance(TypedDict): + """STAC Conformance Classes.""" - type: str - stac_version: str - stac_extensions: Optional[List[str]] - id: str - title: Optional[str] - description: str - links: List[Dict[str, Any]] + conformsTo: List[str] class Collection(Catalog, total=False): @@ -84,7 +78,6 @@ class ItemCollection(TypedDict, total=False): class Collections(TypedDict, total=False): """All collections endpoint. - https://github.com/radiantearth/stac-api-spec/tree/master/collections """ From e7f82d6996af0f28574329d57f5a5e90431d66bb Mon Sep 17 00:00:00 2001 From: Jeff Albrecht Date: Fri, 26 Apr 2024 02:12:07 -0600 Subject: [PATCH 23/57] allow user to pass middleware options (#442) * allow user to pass middleware options to stac api constructor, proxy add_middleware call * update changelog --------- Co-authored-by: Pete Gadomski Co-authored-by: Jonathan Healy Co-authored-by: vincentsarago --- CHANGES.md | 6 +++++- stac_fastapi/api/stac_fastapi/api/app.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b47b03aa3..f12cda471 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,12 +2,16 @@ ## [Unreleased] +### 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 Model to OpenAPI, even if model validation is turned off ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) + ## Changes * Update to pydantic v2 and stac_pydantic v3 ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) * Removed internal Search and Operator Types in favor of stac_pydantic Types ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) * Fix response model validation ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) -* Add Response Model to OpenAPI, even if model validation is turned off ([#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)) * Replace Black with Ruff Format ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 194b22a00..ed9a1cc78 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -12,6 +12,7 @@ from stac_pydantic.api.collections import Collections from stac_pydantic.api.version import STAC_API_VERSION from stac_pydantic.shared import MimeTypes +from starlette.middleware import Middleware from starlette.responses import JSONResponse, Response from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers @@ -109,9 +110,13 @@ class StacApi: ) pagination_extension = attr.ib(default=TokenPaginationExtension) response_class: Type[Response] = attr.ib(default=JSONResponse) - middlewares: List = attr.ib( + middlewares: List[Middleware] = attr.ib( default=attr.Factory( - lambda: [BrotliMiddleware, CORSMiddleware, ProxyHeaderMiddleware] + lambda: [ + Middleware(BrotliMiddleware), + Middleware(CORSMiddleware), + Middleware(ProxyHeaderMiddleware), + ] ) ) route_dependencies: List[Tuple[List[Scope], List[Depends]]] = attr.ib(default=[]) @@ -434,6 +439,11 @@ 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. @@ -478,7 +488,7 @@ def __attrs_post_init__(self): # add middlewares for middleware in self.middlewares: - self.app.add_middleware(middleware) + self.add_middleware(middleware) # customize route dependencies for scopes, dependencies in self.route_dependencies: From e4e4120ae6d1a9fe80910073af5889ae08c418f8 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 2 May 2024 10:19:41 +0200 Subject: [PATCH 24/57] use Collection Pydantic model in PutCollection transaction (#679) * use Collection Pydantic model in PutCollection transaction * update output types --- .../extensions/core/transaction.py | 3 +-- .../extensions/tests/test_transaction.py | 4 ++-- stac_fastapi/types/stac_fastapi/types/core.py | 24 +++++++++---------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 818315e1a..67b6260a9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -10,7 +10,6 @@ from stac_fastapi.api.models import CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint -from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension @@ -34,7 +33,7 @@ class PutItem(ItemUri): class PutCollection(CollectionUri): """Update Collection.""" - collection: stac.Collection = attr.ib(default=Body(None)) + collection: Collection = attr.ib(default=Body(None)) @attr.s diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index d686d8f91..689e519d2 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -2,6 +2,7 @@ from typing import Iterator, Union import pytest +from stac_pydantic import Collection from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection from starlette.testclient import TestClient @@ -10,7 +11,6 @@ from stac_fastapi.extensions.core import TransactionExtension from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient, BaseTransactionsClient -from stac_fastapi.types.stac import Collection class DummyCoreClient(BaseCoreClient): @@ -56,7 +56,7 @@ def create_collection(self, collection: Collection, **kwargs): return {"type": collection.type} def update_collection(self, collection_id: str, collection: Collection, **kwargs): - return {"path_collection_id": collection_id, "type": collection["type"]} + return {"path_collection_id": collection_id, "type": collection.type} def delete_collection(self, collection_id: str, **kwargs): return {"path_collection_id": collection_id} diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index d0dc029f0..fdf020b08 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -38,7 +38,7 @@ def create_item( collection_id: str, item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[Item, Response, None]]: + ) -> Optional[Union[stac.Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -55,7 +55,7 @@ def create_item( @abc.abstractmethod def update_item( self, collection_id: str, item_id: str, item: Item, **kwargs - ) -> Optional[Union[Item, Response]]: + ) -> Optional[Union[stac.Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -75,7 +75,7 @@ def update_item( @abc.abstractmethod def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[Item, Response]]: + ) -> Optional[Union[stac.Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -92,7 +92,7 @@ def delete_item( @abc.abstractmethod def create_collection( self, collection: Collection, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -108,7 +108,7 @@ def create_collection( @abc.abstractmethod def update_collection( self, collection_id: str, collection: Collection, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections/{collection_id}`. It is expected that this @@ -128,7 +128,7 @@ def update_collection( @abc.abstractmethod def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` @@ -152,7 +152,7 @@ async def create_item( collection_id: str, item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[Item, Response, None]]: + ) -> Optional[Union[stac.Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -169,7 +169,7 @@ async def create_item( @abc.abstractmethod async def update_item( self, collection_id: str, item_id: str, item: Item, **kwargs - ) -> Optional[Union[Item, Response]]: + ) -> Optional[Union[stac.Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -188,7 +188,7 @@ async def update_item( @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[Item, Response]]: + ) -> Optional[Union[stac.Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -205,7 +205,7 @@ async def delete_item( @abc.abstractmethod async def create_collection( self, collection: Collection, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -221,7 +221,7 @@ async def create_collection( @abc.abstractmethod async def update_collection( self, collection_id: str, collection: Collection, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections/{collection_id}`. It is expected that this item @@ -241,7 +241,7 @@ async def update_collection( @abc.abstractmethod async def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[Collection, Response]]: + ) -> Optional[Union[stac.Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` From 0de6ace95acd76a4850b2ad95756926119d9fd69 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 6 May 2024 13:13:43 +0200 Subject: [PATCH 25/57] move response class in route definitions (#683) --- CHANGES.md | 1 + .../extensions/core/filter/filter.py | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f12cda471..44cbb2df8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ * 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)) * Replace Black with Ruff Format ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) +* add `response_class` in the route definitions for `FilterExtension` ## [2.5.5.post1] - 2024-04-25 diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py index 2f875907a..0ebc3f9c4 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -97,16 +97,30 @@ def register(self, app: FastAPI) -> None: name="Queryables", path="/queryables", methods=["GET"], - endpoint=create_async_endpoint( - self.client.get_queryables, EmptyRequest, self.response_class - ), + responses={ + 200: { + "content": { + "application/schema+json": {}, + }, + # TODO: add output model in stac-pydantic + }, + }, + response_class=self.response_class, + endpoint=create_async_endpoint(self.client.get_queryables, EmptyRequest), ) self.router.add_api_route( name="Collection Queryables", path="/collections/{collection_id}/queryables", methods=["GET"], - endpoint=create_async_endpoint( - self.client.get_queryables, CollectionUri, self.response_class - ), + responses={ + 200: { + "content": { + "application/schema+json": {}, + }, + # TODO: add output model in stac-pydantic + }, + }, + response_class=self.response_class, + endpoint=create_async_endpoint(self.client.get_queryables, CollectionUri), ) app.include_router(self.router, tags=["Filter Extension"]) From b9cfe2ddc947869b087879b7e8fab5daf31da06a Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 6 May 2024 18:04:31 +0200 Subject: [PATCH 26/57] Release/v3.0.0a0 (#685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * prepare prerelease * Bump version: 2.5.5.post1 → 3.0.0a0 --- CHANGES.md | 5 +- RELEASING.md | 6 +++ VERSION | 2 +- pyproject.toml | 47 +++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/version.py | 2 +- .../stac_fastapi/extensions/version.py | 2 +- .../types/stac_fastapi/types/version.py | 2 +- 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 44cbb2df8..530a01197 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [3.0.0a0] - 2024-05-06 ### Added @@ -361,7 +361,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a0]: [2.5.5.post1]: [2.5.5]: [2.5.4]: diff --git a/RELEASING.md b/RELEASING.md index afd834262..3a23940f6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,6 +5,12 @@ This is a checklist for releasing a new version of **stac-fastapi**. 1. Determine the next version. We currently do not have published versioning guidelines, but there is some text on the subject here: . 2. Create a release branch named `release/vX.Y.Z`, where `X.Y.Z` is the new version. 3. Search and replace all instances of the current version number with the new version. As of this writing, there's 3 different `version.py` files, and one `VERSION` file, in the repo. + + Note: You can use [`bump-my-version`](https://github.com/callowayproject/bump-my-version) CLI + ``` + bump-my-version bump --new-version 3.1.0 + ``` + 4. Update [CHANGES.md](./CHANGES.md) for the new version. Add the appropriate header, and update the links at the bottom of the file. 5. Audit CHANGES.md for completeness and accuracy. Also, ensure that the changes in this version are appropriate for the version number change (i.e. if you're making breaking changes, you should be increasing the `MAJOR` version number). 6. (optional) If you have permissions, run `scripts/publish --test` to test your PyPI publish. If successful, the published packages will be available on . diff --git a/VERSION b/VERSION index 0ee10b8e5..50a693014 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.5.post1 +3.0.0a0 diff --git a/pyproject.toml b/pyproject.toml index ad2edbb00..41f27b63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,50 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo [tool.ruff.format] quote-style = "double" + +[tool.bumpversion] +current_version = "3.0.0a0" +parse = """(?x) + (?P\\d+)\\. + (?P\\d+)\\. + (?P\\d+) + (?: + (?Pa|b|rc) # pre-release label + (?P\\d+) # pre-release version number + )? # pre-release section is optional + (?: + \\.post + (?P\\d+) # post-release version number + )? # post-release section is optional +""" +serialize = [ + "{major}.{minor}.{patch}.post{post_n}", + "{major}.{minor}.{patch}{pre_l}{pre_n}", + "{major}.{minor}.{patch}", +] + +search = "{current_version}" +replace = "{new_version}" +regex = false +tag = false +commit = true + +[[tool.bumpversion.files]] +filename = "VERSION" +search = "{current_version}" +replace = "{new_version}" + +[[tool.bumpversion.files]] +filename = "stac_fastapi/api/stac_fastapi/api/version.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' + +[[tool.bumpversion.files]] +filename = "stac_fastapi/extensions/stac_fastapi/extensions/version.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' + +[[tool.bumpversion.files]] +filename = "stac_fastapi/types/stac_fastapi/types/version.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 017c068d4..bf624bae8 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5.post1" +__version__ = "3.0.0a0" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 017c068d4..bf624bae8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5.post1" +__version__ = "3.0.0a0" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 017c068d4..bf624bae8 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "2.5.5.post1" +__version__ = "3.0.0a0" From 55e1dddf4164bd607801c3c2e4f8bdc00d13957e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 7 May 2024 14:22:03 +0200 Subject: [PATCH 27/57] use literal instead of Enum for FilterLang (#686) --- CHANGES.md | 8 +++++++- .../extensions/core/filter/request.py | 18 ++---------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 530a01197..69e08004b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] - TBD + +## Changed + +* replace Enum with `Literal` for `FilterLang` + ## [3.0.0a0] - 2024-05-06 ### Added @@ -7,7 +13,7 @@ * 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 Model to OpenAPI, even if model validation is turned off ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) -## Changes +## Changed * Update to pydantic v2 and stac_pydantic v3 ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) * Removed internal Search and Operator Types in favor of stac_pydantic Types ([#625](https://github.com/stac-utils/stac-fastapi/pull/625)) 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 1fcd6b0b9..dde015307 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,27 +1,13 @@ """Filter extension request models.""" -from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, Literal, Optional import attr from pydantic import BaseModel, Field from stac_fastapi.types.search import APIRequest - -class FilterLang(str, Enum): - """Choices for filter-lang value in a POST request. - - Based on - https://github.com/stac-api-extensions/filter#queryables - - Note the addition of cql2-json, which is used by the pgstac backend, - but is not included in the spec above. - """ - - cql_json = "cql-json" - cql2_json = "cql2-json" - cql2_text = "cql2-text" +FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] @attr.s From add05de82f745a717b674ada796db0e9f7153e27 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 7 May 2024 14:25:45 +0200 Subject: [PATCH 28/57] switch to fastapi-slim (#687) --- CHANGES.md | 3 ++- stac_fastapi/types/setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 69e08004b..f60e95f9e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,10 @@ # Changelog -## [Unreleased] - TBD +[Unreleased] - TBD ## Changed +* switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies * replace Enum with `Literal` for `FilterLang` ## [3.0.0a0] - 2024-05-06 diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 0b9448e39..55f3ea105 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -6,7 +6,7 @@ desc = f.read() install_requires = [ - "fastapi>=0.100.0", + "fastapi-slim", "attrs>=23.2.0", "pydantic-settings>=2", "stac_pydantic>=3", From 096c93032335d43fd45433297e804f8582c803a5 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 9 May 2024 23:17:07 +0800 Subject: [PATCH 29/57] Remove pystac (#690) * remove pystac * update, fix changelog * fix link * Update stac_fastapi/types/stac_fastapi/types/rfc3339.py Co-authored-by: Pete Gadomski --------- Co-authored-by: Pete Gadomski --- CHANGES.md | 12 +++++--- stac_fastapi/api/setup.py | 1 - stac_fastapi/types/setup.py | 1 - .../types/stac_fastapi/types/rfc3339.py | 29 ++++++++++++++++++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f60e95f9e..940b7b844 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,15 @@ # Changelog -[Unreleased] - TBD +## [Unreleased] - TBD -## Changed +### Changed + +* switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) +* replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) + +### Removed -* switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies -* replace Enum with `Literal` for `FilterLang` +* Pystac as it was just used for a datetime to string function. ([#690](https://github.com/stac-utils/stac-fastapi/pull/690)) ## [3.0.0a0] - 2024-05-06 diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index af596adf0..5050d3a7c 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -18,7 +18,6 @@ "pytest-asyncio", "pre-commit", "requests", - "pystac[validation]==1.*", ], "benchmark": [ "pytest-benchmark", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 55f3ea105..0dd166ec4 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -10,7 +10,6 @@ "attrs>=23.2.0", "pydantic-settings>=2", "stac_pydantic>=3", - "pystac==1.*", "iso8601>=1.0.2,<2.2.0", ] diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 2f0a1f346..a551b45d0 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -6,7 +6,6 @@ import iso8601 from fastapi import HTTPException -from pystac.utils import datetime_to_str RFC33339_PATTERN = ( r"^(\d\d\d\d)\-(\d\d)\-(\d\d)(T|t)(\d\d):(\d\d):(\d\d)([.]\d+)?" @@ -21,6 +20,34 @@ ] +# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394 +def datetime_to_str(dt: datetime, timespec: str = "auto") -> str: + """Converts a :class:`datetime.datetime` instance to an ISO8601 string in the + `RFC 3339, section 5.6 + `__ format required by + the :stac-spec:`STAC Spec `. + + Args: + dt : The datetime to convert. + timespec: An optional argument that specifies the number of additional + terms of the time to include. Valid options are 'auto', 'hours', + 'minutes', 'seconds', 'milliseconds' and 'microseconds'. The default value + is 'auto'. + + Returns: + str: The ISO8601 (RFC 3339) formatted string representing the datetime. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + timestamp = dt.isoformat(timespec=timespec) + zulu = "+00:00" + if timestamp.endswith(zulu): + timestamp = f"{timestamp[: -len(zulu)]}Z" + + return timestamp + + def rfc3339_str_to_datetime(s: str) -> datetime: """Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`. From f815c236cde78a3442fe18c103f43ee1db668872 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 14 May 2024 13:57:25 +0200 Subject: [PATCH 30/57] update conformance and spec urls (#691) --- .../stac_fastapi/extensions/core/transaction.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 67b6260a9..a1c2391f6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -49,17 +49,20 @@ class TransactionExtension(ApiExtension): PUT /collections/{collection_id}/items DELETE /collections/{collection_id}/items - https://github.com/radiantearth/stac-api-spec/blob/master/ogcapi-features/extensions/transaction/README.md + https://github.com/stac-api-extensions/transaction + https://github.com/stac-api-extensions/collection-transaction Attributes: client: CRUD application logic + """ client: Union[AsyncBaseTransactionsClient, BaseTransactionsClient] = attr.ib() settings: ApiSettings = attr.ib() conformance_classes: List[str] = attr.ib( factory=lambda: [ - "https://api.stacspec.org/v1.0.0-rc.3/ogcapi-features/extensions/transaction", + "https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction", + "https://api.stacspec.org/v1.0.0/collections/extensions/transaction", ] ) schema_href: Optional[str] = attr.ib(default=None) @@ -132,6 +135,11 @@ def register_delete_item(self): endpoint=create_async_endpoint(self.client.delete_item, ItemUri), ) + def register_patch_item(self): + """Register patch item endpoint (PATCH + /collections/{collection_id}/items/{item_id}).""" + raise NotImplementedError + def register_create_collection(self): """Register create collection endpoint (POST /collections).""" self.router.add_api_route( @@ -196,6 +204,10 @@ def register_delete_collection(self): endpoint=create_async_endpoint(self.client.delete_collection, CollectionUri), ) + def register_patch_collection(self): + """Register patch collection endpoint (PATCH /collections/{collection_id}).""" + raise NotImplementedError + def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. From 5a4d5b9c57f71ce94edc1d9f925253716195a119 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 21 May 2024 21:14:42 +0200 Subject: [PATCH 31/57] Feature/update stac pydantic3.1.0 (#697) * update stac-pydantic dependendy * replace stac-pydantic todos * fix benchmark * replace deprecated .dict() * fix datetime interval for GET Search * update changelog --- CHANGES.md | 6 ++++-- stac_fastapi/api/stac_fastapi/api/app.py | 4 ++-- stac_fastapi/api/tests/benchmarks.py | 5 +++-- stac_fastapi/api/tests/conftest.py | 1 + stac_fastapi/types/setup.py | 2 +- stac_fastapi/types/stac_fastapi/types/core.py | 12 ++++-------- stac_fastapi/types/stac_fastapi/types/rfc3339.py | 3 +++ 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 940b7b844..abff6ce43 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,8 +4,10 @@ ### Changed -* switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) -* replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) +* Switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) +* Replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) +* Update stac-pydantic requirement to `~3.1` ([#697](https://github.com/stac-utils/stac-fastapi/pull/697)) +* Fix datetime interval for GET Search when passing a single value ([#697](https://github.com/stac-utils/stac-fastapi/pull/697)) ### Removed diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index ed9a1cc78..7eb7c4e80 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -172,14 +172,14 @@ def register_conformance_classes(self): name="Conformance Classes", path="/conformance", response_model=( - api.ConformanceClasses if self.settings.enable_response_models else None + api.Conformance if self.settings.enable_response_models else None ), responses={ 200: { "content": { MimeTypes.json.value: {}, }, - "model": api.ConformanceClasses, + "model": api.Conformance, }, }, response_class=self.response_class, diff --git a/stac_fastapi/api/tests/benchmarks.py b/stac_fastapi/api/tests/benchmarks.py index 95e1c532a..475250d7f 100644 --- a/stac_fastapi/api/tests/benchmarks.py +++ b/stac_fastapi/api/tests/benchmarks.py @@ -17,6 +17,7 @@ collections = [ stac_types.Collection( id=f"test_collection_{n}", + type="Collection", title="Test Collection", description="A test collection", keywords=["test"], @@ -25,7 +26,7 @@ "spatial": {"bbox": [[-180, -90, 180, 90]]}, "temporal": {"interval": [["2000-01-01T00:00:00Z", None]]}, }, - links=collection_links.dict(exclude_none=True), + links=collection_links.model_dump(exclude_none=True), ) for n in range(0, 10) ] @@ -37,7 +38,7 @@ geometry={"type": "Point", "coordinates": [0, 0]}, bbox=[-180, -90, 180, 90], properties={"datetime": "2000-01-01T00:00:00Z"}, - links=item_links.dict(exclude_none=True), + links=item_links.model_dump(exclude_none=True), assets={}, ) for n in range(0, 1000) diff --git a/stac_fastapi/api/tests/conftest.py b/stac_fastapi/api/tests/conftest.py index 1b89f07cd..33919e83e 100644 --- a/stac_fastapi/api/tests/conftest.py +++ b/stac_fastapi/api/tests/conftest.py @@ -16,6 +16,7 @@ @pytest.fixture def _collection(): return Collection( + type="Collection", id="test_collection", title="Test Collection", description="A test collection", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 0dd166ec4..c8f2f9df6 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -9,7 +9,7 @@ "fastapi-slim", "attrs>=23.2.0", "pydantic-settings>=2", - "stac_pydantic>=3", + "stac_pydantic~=3.1", "iso8601>=1.0.2,<2.2.0", ] diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index fdf020b08..ba6ebe440 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -380,10 +380,8 @@ def landing_page(self, **kwargs) -> stac.LandingPage: if self.extension_is_enabled("FilterExtension"): landing_page["links"].append( { - # TODO: replace this with Relations.queryables.value, - "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables", - # TODO: replace this with MimeTypes.jsonschema, - "type": "application/schema+json", + "rel": Relations.queryables.value, + "type": MimeTypes.jsonschema.value, "title": "Queryables", "href": urljoin(base_url, "queryables"), "method": "GET", @@ -586,10 +584,8 @@ async def landing_page(self, **kwargs) -> stac.LandingPage: if self.extension_is_enabled("FilterExtension"): landing_page["links"].append( { - # TODO: replace this with Relations.queryables.value, - "rel": "http://www.opengis.net/def/rel/ogc/1.0/queryables", - # TODO: replace this with MimeTypes.jsonschema, - "type": "application/schema+json", + "rel": Relations.queryables.value, + "type": MimeTypes.jsonschema.value, "title": "Queryables", "href": urljoin(base_url, "queryables"), "method": "GET", diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index a551b45d0..5ba0630aa 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -122,6 +122,9 @@ def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: detail="Interval string contains more than one forward slash.", ) + if len(values) == 1: + values = [values[0], values[0]] + try: start = parse_single_date(values[0]) if values[0] not in ["..", ""] else None end = ( From d2fd4a6ab3a9869d2c2b622fb84c9e9304c9aab1 Mon Sep 17 00:00:00 2001 From: Arjan Date: Wed, 22 May 2024 09:25:12 +0200 Subject: [PATCH 32/57] Make `str_to_interval` not return a tuple for single-value input (#692) * Do not return tuple for single-value input * Add PR reference to changelog * update from main --------- Co-authored-by: vincentsarago --- CHANGES.md | 5 ++- .../types/stac_fastapi/types/rfc3339.py | 15 +++---- stac_fastapi/types/tests/test_rfc3339.py | 42 +++++++++++++------ 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index abff6ce43..d26773a50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,12 +7,15 @@ * Switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) * Replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) * Update stac-pydantic requirement to `~3.1` ([#697](https://github.com/stac-utils/stac-fastapi/pull/697)) -* Fix datetime interval for GET Search when passing a single value ([#697](https://github.com/stac-utils/stac-fastapi/pull/697)) ### Removed * Pystac as it was just used for a datetime to string function. ([#690](https://github.com/stac-utils/stac-fastapi/pull/690)) +### Fixed + +* 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 ### Added diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 5ba0630aa..77ec993dd 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -94,16 +94,17 @@ def parse_single_date(date_str: str) -> datetime: def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: """ - Extract a tuple of datetime objects from an interval string defined by the OGC API. - The interval can either be a single datetime or a range with start and end datetime. + Extract a single datetime object or a tuple of datetime objects from an + interval string defined by the OGC API. The interval can either be a + single datetime or a range with start and end datetime. Args: interval (Optional[str]): The interval string to convert to datetime objects, or None if no datetime is specified. Returns: - Optional[DateTimeType]: A tuple of datetime.datetime objects or - None if input is None. + Optional[DateTimeType]: A single datetime.datetime object, a tuple of + datetime.datetime objects, or None if input is None. Raises: HTTPException: If the string is not valid for various reasons such as being empty, @@ -122,11 +123,11 @@ def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: detail="Interval string contains more than one forward slash.", ) - if len(values) == 1: - values = [values[0], values[0]] - try: start = parse_single_date(values[0]) if values[0] not in ["..", ""] else None + if len(values) == 1: + return start + end = ( parse_single_date(values[1]) if len(values) > 1 and values[1] not in ["..", ""] diff --git a/stac_fastapi/types/tests/test_rfc3339.py b/stac_fastapi/types/tests/test_rfc3339.py index 8d83dbb9d..dc4c897d5 100644 --- a/stac_fastapi/types/tests/test_rfc3339.py +++ b/stac_fastapi/types/tests/test_rfc3339.py @@ -1,4 +1,4 @@ -from datetime import timezone +from datetime import datetime, timezone import pytest from fastapi import HTTPException @@ -86,27 +86,35 @@ def test_parse_valid_str_to_datetime(test_input): @pytest.mark.parametrize("test_input", invalid_intervals) -def test_parse_invalid_interval_to_datetime(test_input): +def test_str_to_interval_with_invalid_interval(test_input): with pytest.raises(HTTPException) as exc_info: str_to_interval(test_input) assert ( exc_info.value.status_code == 400 - ), "Should return a 400 status code for invalid intervals" + ), "str_to_interval should return a 400 status code for invalid interval" -@pytest.mark.parametrize("test_input", valid_intervals) -def test_parse_valid_interval_to_datetime(test_input): - assert str_to_interval(test_input) +@pytest.mark.parametrize("test_input", invalid_datetimes) +def test_str_to_interval_with_invalid_datetime(test_input): + with pytest.raises(HTTPException) as exc_info: + str_to_interval(test_input) + assert ( + exc_info.value.status_code == 400 + ), "str_to_interval should return a 400 status code for invalid datetime" -def test_now_functions() -> None: - now1 = now_in_utc() - now2 = now_in_utc() +@pytest.mark.parametrize("test_input", valid_intervals) +def test_str_to_interval_with_valid_interval(test_input): + assert isinstance( + str_to_interval(test_input), tuple + ), "str_to_interval should return tuple for multi-value input" - assert now1 < now2 - assert now1.tzinfo == timezone.utc - rfc3339_str_to_datetime(now_to_rfc3339_str()) +@pytest.mark.parametrize("test_input", valid_datetimes) +def test_str_to_interval_with_valid_datetime(test_input): + assert isinstance( + str_to_interval(test_input), datetime + ), "str_to_interval should return single datetime for single-value input" def test_str_to_interval_with_none(): @@ -114,3 +122,13 @@ def test_str_to_interval_with_none(): assert ( str_to_interval(None) is None ), "str_to_interval should return None when input is None" + + +def test_now_functions() -> None: + now1 = now_in_utc() + now2 = now_in_utc() + + assert now1 < now2 + assert now1.tzinfo == timezone.utc + + rfc3339_str_to_datetime(now_to_rfc3339_str()) From a6a57655430d11dfb3ed3140057d3339d04ffb0d Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 22 May 2024 09:39:55 +0200 Subject: [PATCH 33/57] BUGFIX: invalid landing page link when `filter-extension` is enabled (#695) * add test demonstrating bug * fix --- stac_fastapi/api/tests/test_app.py | 8 ++++++-- stac_fastapi/types/stac_fastapi/types/core.py | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 9b4e0e828..062575915 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -110,7 +110,8 @@ def test_client_openapi(TestCoreClient): assert component in test_app.app.openapi_schema["components"]["schemas"] -def test_filter_extension(TestCoreClient, item_dict): +@pytest.mark.parametrize("validate", [True, False]) +def test_filter_extension(validate, TestCoreClient, item_dict): """Test if Filter Parameters are passed correctly.""" class FilterClient(TestCoreClient): @@ -159,13 +160,15 @@ def get_search( post_request_model = create_post_request_model([FilterExtension()]) test_app = app.StacApi( - settings=ApiSettings(), + settings=ApiSettings(enable_response_models=validate), client=FilterClient(post_request_model=post_request_model), search_get_request_model=create_get_request_model([FilterExtension()]), search_post_request_model=post_request_model, + extensions=[FilterExtension()], ) with TestClient(test_app.app) as client: + landing = client.get("/") get_search = client.get( "/search", params={ @@ -184,5 +187,6 @@ 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 diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index ba6ebe440..b665d3dc4 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -384,7 +384,6 @@ def landing_page(self, **kwargs) -> stac.LandingPage: "type": MimeTypes.jsonschema.value, "title": "Queryables", "href": urljoin(base_url, "queryables"), - "method": "GET", } ) From 1c3546a5b449edebc7587dab289f73224847c63e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 22 May 2024 13:01:58 +0200 Subject: [PATCH 34/57] Release/v3.0.0a1 (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0a0 → 3.0.0a1 --- CHANGES.md | 5 ++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d26773a50..46d284fe0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0a1] - 2024-05-22 + ### Changed * Switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) @@ -377,7 +379,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a1]: [3.0.0a0]: [2.5.5.post1]: [2.5.5]: diff --git a/VERSION b/VERSION index 50a693014..a6f4248b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a0 +3.0.0a1 diff --git a/pyproject.toml b/pyproject.toml index 41f27b63f..306ae5a71 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.0a0" +current_version = "3.0.0a1" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index bf624bae8..05010b4dc 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a0" +__version__ = "3.0.0a1" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index bf624bae8..05010b4dc 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a0" +__version__ = "3.0.0a1" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index bf624bae8..05010b4dc 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a0" +__version__ = "3.0.0a1" From a8eec23ca48d94f801420e0857f1508f53970499 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 31 May 2024 17:00:03 +0200 Subject: [PATCH 35/57] set default value for Query attribute for `QueryExtensionPostRequest` model (#701) --- CHANGES.md | 6 ++ .../extensions/core/query/request.py | 2 +- stac_fastapi/extensions/tests/test_query.py | 95 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 stac_fastapi/extensions/tests/test_query.py diff --git a/CHANGES.md b/CHANGES.md index 46d284fe0..0229dcfcb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## [Unreleased] - TBD +## [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 ### Changed 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 8b282884a..7f8425e70 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -18,4 +18,4 @@ class QueryExtensionGetRequest(APIRequest): class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] + query: Optional[Dict[str, Dict[str, Any]]] = None diff --git a/stac_fastapi/extensions/tests/test_query.py b/stac_fastapi/extensions/tests/test_query.py new file mode 100644 index 000000000..7674547a1 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_query.py @@ -0,0 +1,95 @@ +import json +from typing import Iterator +from urllib.parse import quote_plus, unquote_plus + +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 QueryExtension +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): + return kwargs.pop("query", None) + + def post_search(self, *args, **kwargs): + return args[0].query + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +@pytest.fixture +def client() -> Iterator[TestClient]: + settings = ApiSettings() + extensions = [QueryExtension()] + + 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_query_get(client: TestClient): + """Test search GET endpoints with query ext.""" + response = client.get( + "/search", + params={"collections": ["test"]}, + ) + assert response.is_success + assert not response.text + + response = client.get( + "/search", + params={ + "collections": ["test"], + "query": quote_plus( + json.dumps({"eo:cloud_cover": {"gte": 95}}), + ), + }, + ) + assert response.is_success, response.json() + query = json.loads(unquote_plus(response.json())) + assert query["eo:cloud_cover"] == {"gte": 95} + + +def test_search_query_post(client: TestClient): + """Test search POST endpoints with query ext.""" + response = client.post( + "/search", + json={ + "collections": ["test"], + }, + ) + + assert response.is_success + assert not response.text + + response = client.post( + "/search", + json={ + "collections": ["test"], + "query": {"eo:cloud_cover": {"gte": 95}}, + }, + ) + + assert response.is_success, response.json() + assert response.json()["eo:cloud_cover"] == {"gte": 95} From fc41f8fd14771641ec6adc22399a3691649aa046 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 31 May 2024 17:24:13 +0200 Subject: [PATCH 36/57] =?UTF-8?q?Bump=20version:=203.0.0a1=20=E2=86=92=203?= =?UTF-8?q?.0.0a2=20(#702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump version: 3.0.0a1 → 3.0.0a2 * update changelog --- CHANGES.md | 3 ++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0229dcfcb..b804733fd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -385,7 +385,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a2]: [3.0.0a1]: [3.0.0a0]: [2.5.5.post1]: diff --git a/VERSION b/VERSION index a6f4248b2..3a5b5bc9d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a1 +3.0.0a2 diff --git a/pyproject.toml b/pyproject.toml index 306ae5a71..8d3573625 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.0a1" +current_version = "3.0.0a2" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 05010b4dc..fd57fd596 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a1" +__version__ = "3.0.0a2" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 05010b4dc..fd57fd596 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a1" +__version__ = "3.0.0a2" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 05010b4dc..fd57fd596 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a1" +__version__ = "3.0.0a2" From 07c890e254e8cbcae0b251ba020e8496510d2525 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 6 Jun 2024 13:10:32 +0200 Subject: [PATCH 37/57] move filter client from types to extensions (#704) * move filter client from types to extensions * update changelog --- CHANGES.md | 4 + .../extensions/core/filter/client.py | 58 +++++++++++++++ .../extensions/core/filter/filter.py | 2 +- stac_fastapi/types/stac_fastapi/types/core.py | 74 ++++++------------- 4 files changed, 87 insertions(+), 51 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/filter/client.py diff --git a/CHANGES.md b/CHANGES.md index b804733fd..a75f1da8a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Changed + +* moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704)) + ## [3.0.0a2] - 2024-05-31 ### Fixed diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/client.py new file mode 100644 index 000000000..03ef96614 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/client.py @@ -0,0 +1,58 @@ +"""Filter extensions clients.""" + +import abc +from typing import Any, Dict, Optional + +import attr + + +@attr.s +class AsyncBaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + async def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all queryables over all + collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } + + +@attr.s +class BaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all queryables over all + collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + https://github.com/stac-api-extensions/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py index 0ebc3f9c4..cd9463ec6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -9,9 +9,9 @@ from stac_fastapi.api.models import CollectionUri, EmptyRequest, JSONSchemaResponse from stac_fastapi.api.routes import create_async_endpoint -from stac_fastapi.types.core import AsyncBaseFiltersClient, BaseFiltersClient from stac_fastapi.types.extension import ApiExtension +from .client import AsyncBaseFiltersClient, BaseFiltersClient from .request import FilterExtensionGetRequest, FilterExtensionPostRequest diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index b665d3dc4..4cdda49e0 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,7 +1,8 @@ """Base clients.""" - import abc +import importlib +import warnings from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin @@ -22,6 +23,16 @@ from stac_fastapi.types.rfc3339 import DateTimeType from stac_fastapi.types.search import BaseSearchPostRequest +__all__ = [ + "NumType", + "StacType", + "BaseTransactionsClient", + "AsyncBaseTransactionsClient", + "LandingPageMixin", + "BaseCoreClient", + "AsyncBaseCoreClient", +] + NumType = Union[float, int] StacType = Dict[str, Any] @@ -737,53 +748,16 @@ async def item_collection( ... -@attr.s -class AsyncBaseFiltersClient(abc.ABC): - """Defines a pattern for implementing the STAC filter extension.""" - - async def get_queryables( - self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """Get the queryables available for the given collection_id. - - If collection_id is None, returns the intersection of all queryables over all - collections. - - This base implementation returns a blank queryable schema. This is not allowed - under OGC CQL but it is allowed by the STAC API Filter Extension - https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables - """ - return { - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://example.org/queryables", - "type": "object", - "title": "Queryables for Example STAC API", - "description": "Queryable names for the example STAC API Item Search filter.", - "properties": {}, - } - - -@attr.s -class BaseFiltersClient(abc.ABC): - """Defines a pattern for implementing the STAC filter extension.""" - - def get_queryables( - self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """Get the queryables available for the given collection_id. - - If collection_id is None, returns the intersection of all queryables over all - collections. +# 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) - This base implementation returns a blank queryable schema. This is not allowed - under OGC CQL but it is allowed by the STAC API Filter Extension - https://github.com/stac-api-extensions/filter#queryables - """ - return { - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://example.org/queryables", - "type": "object", - "title": "Queryables for Example STAC API", - "description": "Queryable names for the example STAC API Item Search filter.", - "properties": {}, - } + raise AttributeError(f"module {__name__} has no attribute {name}") From 8075fc9af0cd5165e4b7661f1c6796cffedc30c7 Mon Sep 17 00:00:00 2001 From: James Fisher <85769594+jamesfisher-gis@users.noreply.github.com> Date: Tue, 11 Jun 2024 03:20:22 -0400 Subject: [PATCH 38/57] Aggregation Extension (#684) * initial commit * aggregation extension and tests * clean up * update changelog * Search and Filter extension * AggregationCollection * AggregationCollection classes * test classes * AggregationCollection literal * aggregation post model * docstring fix * linting * TypedDict import * move aggregation client and types into extensions * linting --- CHANGES.md | 4 + stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../stac_fastapi/extensions/core/__init__.py | 2 + .../extensions/core/aggregation/__init__.py | 5 + .../core/aggregation/aggregation.py | 111 +++++++++++++++ .../extensions/core/aggregation/client.py | 131 ++++++++++++++++++ .../extensions/core/aggregation/request.py | 24 ++++ .../extensions/core/aggregation/types.py | 36 +++++ .../extensions/tests/test_aggregation.py | 102 ++++++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 38 +++++ 10 files changed, 454 insertions(+) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/aggregation.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/client.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py create mode 100644 stac_fastapi/extensions/tests/test_aggregation.py diff --git a/CHANGES.md b/CHANGES.md index a75f1da8a..d6499fb83 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Added + +* Add base support for the Aggregation extension [#684](https://github.com/stac-utils/stac-fastapi/pull/684) + ### Changed * moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704)) diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 3918421ff..20a7b4af5 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum): query = "query" sort = "sort" transaction = "transaction" + aggregation = "aggregation" class AddOns(enum.Enum): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 74f15ed0a..7e29e1fd2 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,5 +1,6 @@ """stac_api.extensions.core module.""" +from .aggregation import AggregationExtension from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension @@ -9,6 +10,7 @@ from .transaction import TransactionExtension __all__ = ( + "AggregationExtension", "ContextExtension", "FieldsExtension", "FilterExtension", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/__init__.py new file mode 100644 index 000000000..2a7fc7a71 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/__init__.py @@ -0,0 +1,5 @@ +"""Aggregation extension module.""" + +from .aggregation import AggregationExtension + +__all__ = ["AggregationExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/aggregation.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/aggregation.py new file mode 100644 index 000000000..c6e892914 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/aggregation.py @@ -0,0 +1,111 @@ +"""Aggregation Extension.""" +from enum import Enum +from typing import List, Union + +import attr +from fastapi import APIRouter, FastAPI + +from stac_fastapi.api.models import CollectionUri, EmptyRequest +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.extension import ApiExtension + +from .client import AsyncBaseAggregationClient, BaseAggregationClient +from .request import AggregationExtensionGetRequest, AggregationExtensionPostRequest + + +class AggregationConformanceClasses(str, Enum): + """Conformance classes for the Aggregation extension. + + See + https://github.com/stac-api-extensions/aggregation + """ + + AGGREGATION = "https://api.stacspec.org/v0.3.0/aggregation" + + +@attr.s +class AggregationExtension(ApiExtension): + """Aggregation Extension. + + The purpose of the Aggregation Extension is to provide an endpoint similar to + the Search endpoint (/search), but which will provide aggregated information + on matching Items rather than the Items themselves. This is highly influenced + by the Elasticsearch and OpenSearch aggregation endpoint, but with a more + regular structure for responses. + + The Aggregation extension adds several endpoints which allow the retrieval of + available aggregation fields and aggregation buckets based on a seearch query: + GET /aggregations + POST /aggregations + GET /collections/{collection_id}/aggregations + POST /collections/{collection_id}/aggregations + GET /aggregate + POST /aggregate + GET /collections/{collection_id}/aggregate + POST /collections/{collection_id}/aggregate + + https://github.com/stac-api-extensions/aggregation/blob/main/README.md + + Attributes: + conformance_classes: Conformance classes provided by the extension + """ + + GET = AggregationExtensionGetRequest + POST = AggregationExtensionPostRequest + + client: Union[AsyncBaseAggregationClient, BaseAggregationClient] = attr.ib( + factory=BaseAggregationClient + ) + + conformance_classes: List[str] = attr.ib( + default=[AggregationConformanceClasses.AGGREGATION] + ) + router: APIRouter = attr.ib(factory=APIRouter) + + 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="Aggregations", + path="/aggregations", + methods=["GET", "POST"], + endpoint=create_async_endpoint(self.client.get_aggregations, EmptyRequest), + ) + self.router.add_api_route( + name="Collection Aggregations", + path="/collections/{collection_id}/aggregations", + methods=["GET", "POST"], + endpoint=create_async_endpoint(self.client.get_aggregations, CollectionUri), + ) + self.router.add_api_route( + name="Aggregate", + path="/aggregate", + methods=["GET"], + endpoint=create_async_endpoint(self.client.aggregate, self.GET), + ) + self.router.add_api_route( + name="Aggregate", + path="/aggregate", + methods=["POST"], + endpoint=create_async_endpoint(self.client.aggregate, self.POST), + ) + self.router.add_api_route( + name="Collection Aggregate", + path="/collections/{collection_id}/aggregate", + methods=["GET"], + endpoint=create_async_endpoint(self.client.aggregate, self.GET), + ) + self.router.add_api_route( + name="Collection Aggregate", + path="/collections/{collection_id}/aggregate", + methods=["POST"], + endpoint=create_async_endpoint(self.client.aggregate, self.POST), + ) + app.include_router(self.router, tags=["Aggregation Extension"]) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/client.py new file mode 100644 index 000000000..23d90fb28 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/client.py @@ -0,0 +1,131 @@ +"""Aggregation extensions clients.""" + +import abc +from typing import List, Optional, Union + +import attr +from geojson_pydantic.geometries import Geometry +from stac_pydantic.shared import BBox + +from stac_fastapi.types.rfc3339 import DateTimeType + +from .types import Aggregation, AggregationCollection + + +@attr.s +class BaseAggregationClient(abc.ABC): + """Defines a pattern for implementing the STAC aggregation extension.""" + + # BUCKET = Bucket + # AGGREGAION = Aggregation + # AGGREGATION_COLLECTION = AggregationCollection + + def get_aggregations( + self, collection_id: Optional[str] = None, **kwargs + ) -> AggregationCollection: + """Get the aggregations available for the given collection_id. + + If collection_id is None, returns the available aggregations over all + collections. + """ + return AggregationCollection( + type="AggregationCollection", + aggregations=[Aggregation(name="total_count", data_type="integer")], + links=[ + { + "rel": "root", + "type": "application/json", + "href": "https://example.org/", + }, + { + "rel": "self", + "type": "application/json", + "href": "https://example.org/aggregations", + }, + ], + ) + + def aggregate( + self, collection_id: Optional[str] = None, **kwargs + ) -> AggregationCollection: + """Return the aggregation buckets for a given search result""" + return AggregationCollection( + type="AggregationCollection", + aggregations=[], + links=[ + { + "rel": "root", + "type": "application/json", + "href": "https://example.org/", + }, + { + "rel": "self", + "type": "application/json", + "href": "https://example.org/aggregations", + }, + ], + ) + + +@attr.s +class AsyncBaseAggregationClient(abc.ABC): + """Defines an async pattern for implementing the STAC aggregation extension.""" + + # BUCKET = Bucket + # AGGREGAION = Aggregation + # AGGREGATION_COLLECTION = AggregationCollection + + async def get_aggregations( + self, collection_id: Optional[str] = None, **kwargs + ) -> AggregationCollection: + """Get the aggregations available for the given collection_id. + + If collection_id is None, returns the available aggregations over all + collections. + """ + return AggregationCollection( + type="AggregationCollection", + aggregations=[Aggregation(name="total_count", data_type="integer")], + links=[ + { + "rel": "root", + "type": "application/json", + "href": "https://example.org/", + }, + { + "rel": "self", + "type": "application/json", + "href": "https://example.org/aggregations", + }, + ], + ) + + async def aggregate( + self, + collection_id: Optional[str] = None, + aggregations: Optional[Union[str, List[str]]] = None, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[BBox] = None, + intersects: Optional[Geometry] = None, + datetime: Optional[DateTimeType] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> AggregationCollection: + """Return the aggregation buckets for a given search result""" + return AggregationCollection( + type="AggregationCollection", + aggregations=[], + links=[ + { + "rel": "root", + "type": "application/json", + "href": "https://example.org/", + }, + { + "rel": "self", + "type": "application/json", + "href": "https://example.org/aggregations", + }, + ], + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py new file mode 100644 index 000000000..fcab3323f --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -0,0 +1,24 @@ +"""Request model for the Aggregation extension.""" + +from typing import List, Optional, Union + +import attr + +from stac_fastapi.extensions.core.filter.request import ( + FilterExtensionGetRequest, + FilterExtensionPostRequest, +) +from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest + + +@attr.s +class AggregationExtensionGetRequest(BaseSearchGetRequest, FilterExtensionGetRequest): + """Aggregation Extension GET request model.""" + + aggregations: Optional[str] = attr.ib(default=None) + + +class AggregationExtensionPostRequest(BaseSearchPostRequest, FilterExtensionPostRequest): + """Aggregation Extension POST request model.""" + + aggregations: Optional[Union[str, List[str]]] = attr.ib(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py new file mode 100644 index 000000000..428b65225 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py @@ -0,0 +1,36 @@ +"""Aggregation Extension types.""" + +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import Field +from typing_extensions import TypedDict + +from stac_fastapi.types.rfc3339 import DateTimeType + + +class Bucket(TypedDict, total=False): + """A STAC aggregation bucket.""" + + key: str + data_type: str + frequency: Optional[Dict] = None + _from: Optional[Union[int, float]] = Field(alias="from", default=None) + to: Optional[Optional[Union[int, float]]] = None + + +class Aggregation(TypedDict, total=False): + """A STAC aggregation.""" + + name: str + data_type: str + buckets: Optional[List[Bucket]] = None + overflow: Optional[int] = None + value: Optional[Union[str, int, DateTimeType]] = None + + +class AggregationCollection(TypedDict, total=False): + """STAC Item Aggregation Collection.""" + + type: Literal["AggregationCollection"] + aggregations: List[Aggregation] + links: List[Dict[str, Any]] diff --git a/stac_fastapi/extensions/tests/test_aggregation.py b/stac_fastapi/extensions/tests/test_aggregation.py new file mode 100644 index 000000000..c96e316ae --- /dev/null +++ b/stac_fastapi/extensions/tests/test_aggregation.py @@ -0,0 +1,102 @@ +from typing import Iterator + +import pytest +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.types import ( + Aggregation, + AggregationCollection, +) +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + raise NotImplementedError + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + raise NotImplementedError + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +def test_get_aggregations(client: TestClient) -> None: + response = client.get("/aggregations") + assert response.is_success, response.text + assert response.json()["aggregations"] == [ + {"name": "total_count", "data_type": "integer"} + ] + assert AggregationCollection( + type="AggregationCollection", + aggregations=[Aggregation(**response.json()["aggregations"][0])], + ) + + +def test_get_aggregate(client: TestClient) -> None: + response = client.get("/aggregate") + assert response.is_success, response.text + assert response.json()["aggregations"] == [] + assert AggregationCollection( + type="AggregationCollection", aggregations=response.json()["aggregations"] + ) + + +def test_post_aggregations(client: TestClient) -> None: + response = client.post("/aggregations") + assert response.is_success, response.text + assert response.json()["aggregations"] == [ + {"name": "total_count", "data_type": "integer"} + ] + assert AggregationCollection( + type="AggregationCollection", + aggregations=[Aggregation(**response.json()["aggregations"][0])], + ) + + +def test_post_aggregate(client: TestClient) -> None: + response = client.post("/aggregate", content="{}") + assert response.is_success, response.text + assert response.json()["aggregations"] == [] + assert AggregationCollection( + type="AggregationCollection", aggregations=response.json()["aggregations"] + ) + + +@pytest.fixture +def client( + core_client: DummyCoreClient, aggregations_client: BaseAggregationClient +) -> Iterator[TestClient]: + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + AggregationExtension(client=aggregations_client), + ], + ) + with TestClient(api.app) as client: + yield client + + +@pytest.fixture +def core_client() -> DummyCoreClient: + return DummyCoreClient() + + +@pytest.fixture +def aggregations_client() -> BaseAggregationClient: + return BaseAggregationClient() diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 4cdda49e0..003a765ed 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -398,6 +398,25 @@ def landing_page(self, **kwargs) -> stac.LandingPage: } ) + # Add Aggregation links + if self.extension_is_enabled("AggregationExtension"): + landing_page["links"].extend( + [ + { + "rel": "aggregate", + "type": "application/json", + "title": "Aggregate", + "href": urljoin(base_url, "aggregate"), + }, + { + "rel": "aggregations", + "type": "application/json", + "title": "Aggregations", + "href": urljoin(base_url, "aggregations"), + }, + ] + ) + # Add Collections links collections = self.all_collections(request=kwargs["request"]) @@ -602,6 +621,25 @@ async def landing_page(self, **kwargs) -> stac.LandingPage: } ) + # Add Aggregation links + if self.extension_is_enabled("AggregationExtension"): + landing_page["links"].extend( + [ + { + "rel": "aggregate", + "type": "application/json", + "title": "Aggregate", + "href": urljoin(base_url, "aggregate"), + }, + { + "rel": "aggregations", + "type": "application/json", + "title": "Aggregations", + "href": urljoin(base_url, "aggregations"), + }, + ] + ) + # Add Collections links collections = await self.all_collections(request=kwargs["request"]) From 9a8ab844d9d3ceb4a258e112a4328a8ddabe27c1 Mon Sep 17 00:00:00 2001 From: rhysrevans3 <34507919+rhysrevans3@users.noreply.github.com> Date: Wed, 12 Jun 2024 08:43:31 +0100 Subject: [PATCH 39/57] Allow default route dependencies (#705) * Allowing for default route dependencies. * Running precommit hooks. * Adding pull request to CHANGELOG. * Update stac_fastapi/api/stac_fastapi/api/routes.py Co-authored-by: Anthony Lukach * Fixing indenting. --------- Co-authored-by: Jonathan Healy Co-authored-by: Anthony Lukach --- CHANGES.md | 1 + stac_fastapi/api/stac_fastapi/api/routes.py | 16 +- stac_fastapi/api/tests/test_api.py | 272 ++++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index d6499fb83..e1a7e29ff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ ### Changed +* 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)) ## [3.0.0a2] - 2024-05-31 diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index df4a136eb..bd6f4d9cf 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -1,5 +1,6 @@ """Route factories.""" +import copy import functools import inspect import warnings @@ -100,15 +101,28 @@ def add_route_dependencies( Allows a developer to add dependencies to a route after the route has been defined. + "*" can be used for path or method to match all allowed routes. + Returns: None """ for scope in scopes: + _scope = copy.deepcopy(scope) for route in routes: - match, _ = route.matches({"type": "http", **scope}) + if scope["path"] == "*": + _scope["path"] = route.path + + if scope["method"] == "*": + _scope["method"] = list(route.methods)[0] + + match, _ = route.matches({"type": "http", **_scope}) if match != Match.FULL: continue + # Ignore paths without dependants, e.g. /api, /api.html, /docs/oauth2-redirect + if not hasattr(route, "dependant"): + continue + # Mimicking how APIRoute handles dependencies: # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412 for depends in dependencies[::-1]: diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index f2d51f1db..deff0c070 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -49,6 +49,24 @@ def _assert_dependency_applied(api, routes): ), "Authenticated requests should be accepted" assert response.json() == "dummy response" + @staticmethod + def _assert_dependency_not_applied(api, routes): + with TestClient(api.app) as client: + for route in routes: + path = route["path"].format( + collectionId="test_collection", itemId="test_item" + ) + response = client.request( + method=route["method"].lower(), + url=path, + content=route["payload"], + headers={"content-type": "application/json"}, + ) + assert ( + 200 <= response.status_code < 300 + ), "Authenticated requests should be accepted" + assert response.json() == "dummy response" + def test_openapi_content_type(self): api = self._build_api() with TestClient(api.app) as client: @@ -116,6 +134,260 @@ def test_add_route_dependencies_after_building_api(self, collection, item): api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) self._assert_dependency_applied(api, routes) + def test_build_api_with_default_route_dependencies(self, collection, item): + routes = [{"path": "*", "method": "*"}] + test_routes = [ + {"path": "/collections", "method": "POST", "payload": collection}, + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""}, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + dependencies = [Depends(must_be_bob)] + api = self._build_api(route_dependencies=[(routes, dependencies)]) + self._assert_dependency_applied(api, test_routes) + + def test_build_api_with_default_path_route_dependencies(self, collection, item): + routes = [{"path": "*", "method": "POST"}] + test_routes = [ + { + "path": "/collections", + "method": "POST", + "payload": collection, + }, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + ] + test_not_routes = [ + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "DELETE", + "payload": "", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + dependencies = [Depends(must_be_bob)] + api = self._build_api(route_dependencies=[(routes, dependencies)]) + self._assert_dependency_applied(api, test_routes) + self._assert_dependency_not_applied(api, test_not_routes) + + def test_build_api_with_default_method_route_dependencies(self, collection, item): + routes = [ + { + "path": "/collections/{collectionId}", + "method": "*", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "*", + }, + ] + test_routes = [ + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "DELETE", + "payload": "", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + test_not_routes = [ + { + "path": "/collections", + "method": "POST", + "payload": collection, + }, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + ] + dependencies = [Depends(must_be_bob)] + api = self._build_api(route_dependencies=[(routes, dependencies)]) + self._assert_dependency_applied(api, test_routes) + self._assert_dependency_not_applied(api, test_not_routes) + + def test_add_default_route_dependencies_after_building_api(self, collection, item): + routes = [{"path": "*", "method": "*"}] + test_routes = [ + { + "path": "/collections", + "method": "POST", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "DELETE", + "payload": "", + }, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + api = self._build_api() + api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) + self._assert_dependency_applied(api, test_routes) + + def test_add_default_path_route_dependencies_after_building_api( + self, collection, item + ): + routes = [{"path": "*", "method": "POST"}] + test_routes = [ + { + "path": "/collections", + "method": "POST", + "payload": collection, + }, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + ] + test_not_routes = [ + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "DELETE", + "payload": "", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + api = self._build_api() + api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) + self._assert_dependency_applied(api, test_routes) + self._assert_dependency_not_applied(api, test_not_routes) + + def test_add_default_method_route_dependencies_after_building_api( + self, collection, item + ): + routes = [ + { + "path": "/collections/{collectionId}", + "method": "*", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "*", + }, + ] + test_routes = [ + { + "path": "/collections/{collectionId}", + "method": "PUT", + "payload": collection, + }, + { + "path": "/collections/{collectionId}", + "method": "DELETE", + "payload": "", + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + "payload": "", + }, + ] + test_not_routes = [ + { + "path": "/collections", + "method": "POST", + "payload": collection, + }, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + ] + api = self._build_api() + api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) + self._assert_dependency_applied(api, test_routes) + self._assert_dependency_not_applied(api, test_not_routes) + class DummyCoreClient(core.BaseCoreClient): def all_collections(self, *args, **kwargs): From d8528ae2e0d93572219869458b0157fbeeaf2ba3 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 13 Jun 2024 13:01:00 +0200 Subject: [PATCH 40/57] deprecate Fields default-includes (#706) --- CHANGES.md | 4 +- docs/src/tips-and-tricks.md | 52 +++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 4 -- stac_fastapi/api/tests/test_api.py | 5 +- .../extensions/core/fields/fields.py | 15 +----- .../extensions/core/fields/request.py | 12 ++++- .../types/stac_fastapi/types/config.py | 6 +-- 7 files changed, 71 insertions(+), 27 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e1a7e29ff..1c5ed2c00 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,7 +9,9 @@ ### Changed * 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)) +* Moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704)) +* 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 diff --git a/docs/src/tips-and-tricks.md b/docs/src/tips-and-tricks.md index a5ca8cb68..ef9a97ae8 100644 --- a/docs/src/tips-and-tricks.md +++ b/docs/src/tips-and-tricks.md @@ -45,3 +45,55 @@ For the landing page, you can set the API title, description and version using e - `STAC FASTAPI_TITLE` (string) should be a self-explanatory title for your API. - `STAC FASTAPI_DESCRIPTION` (string) should be a good description for your API. It can contain CommonMark. - `STAC_FASTAPI_LANDING_ID` (string) is a unique identifier for your Landing page. + + +## Default `includes` in Fields extension (POST request) + +The [**Fields** API extension](https://github.com/stac-api-extensions/fields) enables to filter in/out STAC Items keys (e.g `geometry`). The default behavior is to not filter out anything, but this can be overridden by providing a custom `FieldsExtensionPostRequest` class: + +```python +from typing import Optional, Set + +import attr +from stac_fastapi.extensions import FieldsExtension as FieldsExtensionBase +from stac_fastapi.extensions.core.fields import requests +from pydantic import BaseModel, Field + + +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() + ] +) +``` diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 7eb7c4e80..5fe7f9d08 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -458,10 +458,6 @@ def __attrs_post_init__(self): self.client.title = self.title self.client.description = self.description - fields_ext = self.get_extension(FieldsExtension) - if fields_ext: - self.settings.default_includes = fields_ext.default_includes - Settings.set(self.settings) self.app.state.settings = self.settings diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index deff0c070..d559a377a 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -2,7 +2,10 @@ from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi -from stac_fastapi.extensions.core import TokenPaginationExtension, TransactionExtension +from stac_fastapi.extensions.core import ( + TokenPaginationExtension, + TransactionExtension, +) from stac_fastapi.types import config, core diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py index 25b6fe252..90b4b2697 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py @@ -1,6 +1,6 @@ """Fields extension.""" -from typing import List, Optional, Set +from typing import List, Optional import attr from fastapi import FastAPI @@ -35,19 +35,6 @@ class FieldsExtension(ApiExtension): conformance_classes: List[str] = attr.ib( factory=lambda: ["https://api.stacspec.org/v1.0.0/item-search#fields"] ) - default_includes: Set[str] = attr.ib( - factory=lambda: { - "id", - "type", - "stac_version", - "geometry", - "bbox", - "links", - "assets", - "properties.datetime", - "collection", - } - ) schema_href: Optional[str] = attr.ib(default=None) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index 4cfbd3293..e08572ca0 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,11 +1,11 @@ """Request models for the fields extension.""" +import warnings from typing import Dict, Optional, Set import attr from pydantic import BaseModel, Field -from stac_fastapi.types.config import Settings from stac_fastapi.types.search import APIRequest, str2list @@ -39,6 +39,7 @@ def _get_field_dict(fields: Optional[Set[str]]) -> Dict: field_dict[parent].add(key) else: field_dict[field] = ... # type:ignore + return field_dict @property @@ -49,10 +50,17 @@ def filter_fields(self) -> Dict: 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 |= Settings.get().default_includes or set() + include |= set() return { "include": self._get_field_dict(include), diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index d692043cc..75d0bd399 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,6 +1,6 @@ """stac_fastapi.types.config module.""" -from typing import Optional, Set +from typing import Optional from pydantic_settings import BaseSettings, SettingsConfigDict @@ -19,10 +19,6 @@ class ApiSettings(BaseSettings): as distinct columns in the database. """ - # TODO: Remove `default_includes` attribute so we can use - # `pydantic.BaseSettings` instead - default_includes: Optional[Set[str]] = None - stac_fastapi_title: str = "stac-fastapi" stac_fastapi_description: str = "stac-fastapi" stac_fastapi_version: str = "0.1" From 51a756d904e4bab245da4a525e961d64bcbf1321 Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Thu, 13 Jun 2024 20:05:48 +0800 Subject: [PATCH 41/57] v3.0.0a3 (#707) --- CHANGES.md | 5 ++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1c5ed2c00..49920eb90 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0a3] - 2024-06-13 + ### Added * Add base support for the Aggregation extension [#684](https://github.com/stac-utils/stac-fastapi/pull/684) @@ -396,7 +398,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a3]: [3.0.0a2]: [3.0.0a1]: [3.0.0a0]: diff --git a/VERSION b/VERSION index 3a5b5bc9d..4f22bc78a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a2 +3.0.0a3 diff --git a/pyproject.toml b/pyproject.toml index 8d3573625..b56750993 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.0a2" +current_version = "3.0.0a3" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index fd57fd596..0ead30261 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a2" +__version__ = "3.0.0a3" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index fd57fd596..0ead30261 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a2" +__version__ = "3.0.0a3" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index fd57fd596..0ead30261 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a2" +__version__ = "3.0.0a3" From 68dfbd58cabe6eedd6e607b1282376628231036e Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Fri, 14 Jun 2024 13:14:56 +0800 Subject: [PATCH 42/57] fix import errors (#710) --- docs/src/tips-and-tricks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/tips-and-tricks.md b/docs/src/tips-and-tricks.md index ef9a97ae8..5398112ef 100644 --- a/docs/src/tips-and-tricks.md +++ b/docs/src/tips-and-tricks.md @@ -55,8 +55,8 @@ The [**Fields** API extension](https://github.com/stac-api-extensions/fields) en from typing import Optional, Set import attr -from stac_fastapi.extensions import FieldsExtension as FieldsExtensionBase -from stac_fastapi.extensions.core.fields import requests +from stac_fastapi.extensions.core import FieldsExtension as FieldsExtensionBase +from stac_fastapi.extensions.core.fields import request from pydantic import BaseModel, Field From 80064f77739ddc1acaafa78cb0d8a11f77ecd33e Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 14 Jun 2024 13:28:37 +0200 Subject: [PATCH 43/57] add tests for FieldsExtension impact on validation (#708) Co-authored-by: Jonathan Healy --- stac_fastapi/api/tests/test_app.py | 108 ++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 062575915..829982b51 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -8,10 +8,10 @@ from stac_fastapi.api import app from stac_fastapi.api.models import create_get_request_model, create_post_request_model -from stac_fastapi.extensions.core.filter.filter import FilterExtension +from stac_fastapi.extensions.core import FieldsExtension, FilterExtension from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings -from stac_fastapi.types.core import NumType +from stac_fastapi.types.core import BaseCoreClient, NumType from stac_fastapi.types.search import BaseSearchPostRequest @@ -190,3 +190,107 @@ def get_search( assert landing.status_code == 200, landing.text assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + + +@pytest.mark.parametrize("validate", [True, False]) +def test_fields_extension(validate, TestCoreClient, item_dict): + """Test if fields Parameters are passed correctly.""" + + class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: + raise NotImplementedError + + def all_collections(self, **kwargs) -> stac.Collections: + raise NotImplementedError + + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: + raise NotImplementedError + + def item_collection( + self, + collection_id: str, + bbox: Optional[List[Union[float, int]]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: int = 10, + token: str = None, + **kwargs, + ) -> stac.ItemCollection: + raise NotImplementedError + + test_app = app.StacApi( + settings=ApiSettings(enable_response_models=validate), + client=BadCoreClient(), + search_get_request_model=create_get_request_model([FieldsExtension()]), + search_post_request_model=create_post_request_model([FieldsExtension()]), + extensions=[FieldsExtension()], + ) + + with TestClient(test_app.app) as client: + get_search = client.get( + "/search", + params={"fields": "properties.datetime"}, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + "fields": { + "include": ["properties.datetime"], + "exclude": [], + }, + }, + ) + + assert get_search.status_code == 200, get_search.text + assert post_search.status_code == 200, post_search.text + + test_app = app.StacApi( + settings=ApiSettings(enable_response_models=validate), + client=BadCoreClient(), + search_get_request_model=create_get_request_model([FieldsExtension()]), + search_post_request_model=create_post_request_model([FieldsExtension()]), + extensions=[], + ) + + with TestClient(test_app.app) as client: + get_search = client.get( + "/search", + params={"fields": "properties.datetime"}, + ) + post_search = client.post( + "/search", + json={ + "collections": ["test"], + "fields": { + "include": ["properties.datetime"], + "exclude": [], + }, + }, + ) + if validate: + assert get_search.status_code == 500, ( + get_search.json()["code"] == "ResponseValidationError" + ) + assert post_search.status_code == 500, ( + post_search.json()["code"] == "ResponseValidationError" + ) + else: + assert get_search.status_code == 200, get_search.text + assert post_search.status_code == 200, post_search.text From 8f400e18f361132b92f32d8e3fd62787b3276adc Mon Sep 17 00:00:00 2001 From: Tom Christian <64801328+captaincoordinates@users.noreply.github.com> Date: Fri, 21 Jun 2024 00:58:48 -0700 Subject: [PATCH 44/57] fix(#711): changed default filter language (#712) --- CHANGES.md | 4 + .../extensions/core/filter/request.py | 2 +- stac_fastapi/extensions/tests/test_filter.py | 75 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 stac_fastapi/extensions/tests/test_filter.py diff --git a/CHANGES.md b/CHANGES.md index 49920eb90..3d63fea65 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Fixed + +* Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) + ## [3.0.0a3] - 2024-06-13 ### Added diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index dde015307..35a17bf36 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -24,4 +24,4 @@ class FilterExtensionPostRequest(BaseModel): filter: Optional[Dict[str, Any]] = None filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql-json") + filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-json") diff --git a/stac_fastapi/extensions/tests/test_filter.py b/stac_fastapi/extensions/tests/test_filter.py new file mode 100644 index 000000000..ca72dc51a --- /dev/null +++ b/stac_fastapi/extensions/tests/test_filter.py @@ -0,0 +1,75 @@ +from typing import Iterator + +import pytest +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.extensions.core import FilterExtension +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + raise NotImplementedError + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + return args[0].model_dump() + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +@pytest.fixture +def client() -> Iterator[TestClient]: + settings = ApiSettings() + extensions = [FilterExtension()] + api = StacApi( + settings=settings, + client=DummyCoreClient(), + extensions=extensions, + search_get_request_model=create_get_request_model(extensions), + search_post_request_model=create_post_request_model(extensions), + ) + with TestClient(api.app) as client: + yield client + + +def test_search_filter_post_filter_lang_default(client: TestClient): + """Test search POST endpoint with filter ext.""" + response = client.post( + "/search", + json={ + "collections": ["test"], + "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["filter_lang"] == "cql2-json" + + +def test_search_filter_post_filter_lang_non_default(client: TestClient): + """Test search POST endpoint with filter ext.""" + filter_lang_value = "cql2-text" + response = client.post( + "/search", + json={ + "collections": ["test"], + "filter": {"op": "=", "args": [{"property": "test_property"}, "test-value"]}, + "filter-lang": filter_lang_value, + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["filter_lang"] == filter_lang_value From f311dc620d2a11df397f8d95c17e15483b9e302c Mon Sep 17 00:00:00 2001 From: James Fisher <85769594+jamesfisher-gis@users.noreply.github.com> Date: Tue, 25 Jun 2024 09:43:29 -0400 Subject: [PATCH 45/57] Remove the Filter Extension dependency from Aggregation Extension requests (#716) * aggregations type * aggregations type * remove filter extension dependency * linting * update changelog --- CHANGES.md | 4 ++++ .../extensions/core/aggregation/request.py | 18 +++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3d63fea65..ea0209926 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,10 @@ * Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) +### Removed + +* Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) + ## [3.0.0a3] - 2024-06-13 ### Added diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index fcab3323f..08ebe0cfc 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,24 +1,24 @@ """Request model for the Aggregation extension.""" -from typing import List, Optional, Union +from typing import List, Optional import attr -from stac_fastapi.extensions.core.filter.request import ( - FilterExtensionGetRequest, - FilterExtensionPostRequest, +from stac_fastapi.types.search import ( + BaseSearchGetRequest, + BaseSearchPostRequest, + str2list, ) -from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest @attr.s -class AggregationExtensionGetRequest(BaseSearchGetRequest, FilterExtensionGetRequest): +class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Optional[str] = attr.ib(default=None) + aggregations: Optional[str] = attr.ib(default=None, converter=str2list) -class AggregationExtensionPostRequest(BaseSearchPostRequest, FilterExtensionPostRequest): +class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[Union[str, List[str]]] = attr.ib(default=None) + aggregations: Optional[List[str]] = attr.ib(default=None) From 4f410eb8756e253cf224eb4ad8feb533ad2273c1 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 27 Jun 2024 17:59:40 +0200 Subject: [PATCH 46/57] remove middleware stack building to avoid conflict with exception handler (#721) * remove middleware stack building to avoid conflict with exception handler * update changelog --- CHANGES.md | 1 + stac_fastapi/api/stac_fastapi/api/app.py | 7 +----- stac_fastapi/api/tests/test_middleware.py | 30 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ea0209926..011a7fde7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,7 @@ ### Removed * Removed the Filter Extension depenency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) +* Removed `add_middleware` method in `StacApi` object and let starlette handle the middleware stack creation [721](https://github.com/stac-utils/stac-fastapi/pull/721) ## [3.0.0a3] - 2024-06-13 diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 5fe7f9d08..b4f5125f0 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -439,11 +439,6 @@ def add_route_dependencies( """ return add_route_dependencies(self.app.router.routes, scopes, dependencies) - def add_middleware(self, middleware: Middleware): - """Add a middleware class to the application.""" - self.app.user_middleware.insert(0, middleware) - self.app.middleware_stack = self.app.build_middleware_stack() - def __attrs_post_init__(self): """Post-init hook. @@ -484,7 +479,7 @@ def __attrs_post_init__(self): # add middlewares for middleware in self.middlewares: - self.add_middleware(middleware) + self.app.user_middleware.insert(0, middleware) # customize route dependencies for scopes, dependencies in self.route_dependencies: diff --git a/stac_fastapi/api/tests/test_middleware.py b/stac_fastapi/api/tests/test_middleware.py index 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" From 63097135d72d1afe8731b6138af1974aace477af Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 27 Jun 2024 22:19:43 +0200 Subject: [PATCH 47/57] raise RuntimeError if middleware stack has already been created when initialiazing StacApi (#722) --- stac_fastapi/api/stac_fastapi/api/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index b4f5125f0..44a55764f 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -478,6 +478,9 @@ def __attrs_post_init__(self): self.app.openapi = self.customize_openapi # add middlewares + if self.middlewares and self.app.middleware_stack is not None: + raise RuntimeError("Cannot add middleware after an application has started") + for middleware in self.middlewares: self.app.user_middleware.insert(0, middleware) From 1916d44397733ff09c247c1578e113ef3f7a2501 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 28 Jun 2024 17:55:13 +0200 Subject: [PATCH 48/57] Release/v3.0.0a4 (#723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0a3 → 3.0.0a4 --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 5 ++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 011a7fde7..2e32b5814 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0a4] - 2024-06-27 + ### Fixed * Updated default filter language in filter extension's POST search request model to match the extension's documentation [#711](https://github.com/stac-utils/stac-fastapi/issues/711) @@ -407,7 +409,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0a4]: [3.0.0a3]: [3.0.0a2]: [3.0.0a1]: diff --git a/VERSION b/VERSION index 4f22bc78a..255dd065c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a3 +3.0.0a4 diff --git a/pyproject.toml b/pyproject.toml index b56750993..fbe51fbf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0a3" +current_version = "3.0.0a4" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 0ead30261..f182485f7 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 0ead30261..f182485f7 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 0ead30261..f182485f7 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a3" +__version__ = "3.0.0a4" From 270e03d29e3d4369c977812de5a6e731662e4cf0 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 1 Jul 2024 20:37:19 +0200 Subject: [PATCH 49/57] move pagination models to extensions submodule (#717) * move pagination models to extensions submodule * remove models from api * update changelog * update changelog --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 4 +++ stac_fastapi/api/stac_fastapi/api/models.py | 26 -------------- .../extensions/core/pagination/pagination.py | 3 +- .../extensions/core/pagination/request.py | 34 +++++++++++++++++++ .../core/pagination/token_pagination.py | 3 +- 5 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py diff --git a/CHANGES.md b/CHANGES.md index 2e32b5814..2b74edbb1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] - TBD +### Changed + +* moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) + ## [3.0.0a4] - 2024-06-27 ### Fixed diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 2716fe7fb..307be14a7 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -110,32 +110,6 @@ class ItemCollectionUri(CollectionUri): datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) -class POSTTokenPagination(BaseModel): - """Token pagination model for POST requests.""" - - token: Optional[str] = None - - -@attr.s -class GETTokenPagination(APIRequest): - """Token pagination for GET requests.""" - - token: Optional[str] = attr.ib(default=None) - - -class POSTPagination(BaseModel): - """Page based pagination for POST requests.""" - - page: Optional[str] = None - - -@attr.s -class GETPagination(APIRequest): - """Page based pagination for GET requests.""" - - page: Optional[str] = attr.ib(default=None) - - # Test for ORJSON and use it rather than stdlib JSON where supported if importlib.util.find_spec("orjson") is not None: from fastapi.responses import ORJSONResponse diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py index 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..9524ee324 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -0,0 +1,34 @@ +"""Pagination extension request models.""" + +from typing import Optional + +import attr +from pydantic import BaseModel + +from stac_fastapi.types.search import APIRequest + + +@attr.s +class GETTokenPagination(APIRequest): + """Token pagination for GET requests.""" + + token: Optional[str] = attr.ib(default=None) + + +class POSTTokenPagination(BaseModel): + """Token pagination model for POST requests.""" + + token: Optional[str] = None + + +@attr.s +class GETPagination(APIRequest): + """Page based pagination for GET requests.""" + + page: Optional[str] = attr.ib(default=None) + + +class POSTPagination(BaseModel): + """Page based pagination for POST requests.""" + + page: Optional[str] = None diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py index 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): From 1062ac44ad433defc765354130e4570f39402ee7 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 1 Jul 2024 20:46:23 +0200 Subject: [PATCH 50/57] remove pagination extension dependency and add request model attributes (#718) * remove pagination extension dependency and add request model attributes * add migration guide --------- Co-authored-by: Jonathan Healy --- CHANGES.md | 16 ++- docs/mkdocs.yml | 2 + docs/src/migrations/v3.0.0.md | 163 +++++++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 35 ++--- stac_fastapi/api/tests/test_api.py | 9 ++ stac_fastapi/api/tests/test_app.py | 71 +++++++++- 6 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 docs/src/migrations/v3.0.0.md diff --git a/CHANGES.md b/CHANGES.md index 2b74edbb1..fc4a11e9e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,9 +2,23 @@ ## [Unreleased] - TBD +### Added + +* Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for: + - `/collections`: **collections_get_request_model**, default to `EmptyRequest` + - `/collections/{collection_id}`: **collection_get_request_model**, default to `CollectionUri` + - `/collections/{collection_id}/items`: **items_get_request_model**, default to `ItemCollectionUri` + - `/collections/{collection_id}/items/{item_id}`: **item_get_request_model**, default to `ItemUri` + +### Removed + +* Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) +* Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` +* Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) + ### Changed -* moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) +* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) ## [3.0.0a4] - 2024-06-27 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dff2035ca..e5326e27f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -74,6 +74,8 @@ nav: - search: api/stac_fastapi/types/search.md - stac: api/stac_fastapi/types/stac.md - version: api/stac_fastapi/types/version.md + - Migration Guides: + - v2.5 -> v3.0: migrations/v3.0.0.md - Performance Benchmarks: benchmarks.html - Development - Contributing: "contributing.md" - Release Notes: "release-notes.md" diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md new file mode 100644 index 000000000..6cbb3605a --- /dev/null +++ b/docs/src/migrations/v3.0.0.md @@ -0,0 +1,163 @@ + +# stac-fastapi v3.0 Migration Guide + +This document aims to help you update your application from **stac-fastapi** 2.5 to 3.0.0. + +## Dependencies + +- **pydantic~=2.0** +- **fastapi>=0.111** +- **stac-pydantic~=3.1** + +Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pydantic v1 to v2 is mostly the one update bringing most breaking changes (see https://docs.pydantic.dev/latest/migration/). + +In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21) + + +## Deprecation + +* the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification. + +* `stac_fastapi.api.config_openapi` method was removed (see https://github.com/stac-utils/stac-fastapi/pull/523) + +* passing `response_class` in `stac_fastapi.api.routes.create_async_endpoint` is now deprecated. The response class now has to be set when registering the endpoint to the application (see https://github.com/stac-utils/stac-fastapi/issues/461) + +* `PostFieldsExtension.filter_fields` property has been removed. + +## Middlewares configuration + +The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). + +```python +# before +class myMiddleware(mainMiddleware): + option1 = option1 + option2 = option2 + +stac = StacApi( + middlewares=[ + myMiddleware, + ] +) + +# now +stac = StacApi( + middlewares=[ + Middleware(myMiddleware, option1, option2), + ] +) +``` + +## Request Models + +In stac-fastapi v2.0, users could already customize both GET/POST search request models. For v3.0, we've added more attributes to enable other endpoints customization: + +- `collections_get_request_model`: GET request model for the `/collections` endpoint (default to `EmptyRequest`) +- `collection_get_request_model`: GET request model for the `/collections/{collection_id}` endpoint (default to `stac_fastapi.api.models.CollectionUri`) +- `items_get_request_model`: GET request model for the `/collections/{collection_id}/items` endpoint (default to `stac_fastapi.api.models.ItemCollectionUri`) +- `item_get_request_model`: GET request model for the `/collections/{collection_id}/items/{item_id}` endpoint (default to `stac_fastapi.api.models.ItemUri`) + +```python +# before +getSearchModel = create_request_model( + model_name="SearchGetRequest", + base_model=BaseSearchGetRequest + extensions=[...], + request_type="GET" +) +stac = StacApi( + search_get_request_model=getSearchModel, + search_post_request_model=..., +) + +# now +@dataclass +class CollectionsRequest(APIRequest): + user: str = Query(...) + +stac = StacApi( + search_get_request_model=getSearchModel, + search_post_request_model=postSearchModel, + collections_get_request_model=CollectionsRequest, + collection_get_request_model=..., + items_get_request_model=..., + item_get_request_model=..., +) +``` + +## Filter extension + +`default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). + +```python +# before +stac = StacApi( + extensions=[ + FieldsExtension() + ] +) + +# now +class PostFieldsExtension(requests.PostFieldsExtension): + include: Optional[Set[str]] = Field( + default_factory=lambda: { + "id", + "type", + "stac_version", + "geometry", + "bbox", + "links", + "assets", + "properties.datetime", + "collection", + } + ) + exclude: Optional[Set[str]] = set() + + +class FieldsExtensionPostRequest(BaseModel): + """Additional fields and schema for the POST request.""" + + fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) + + +class FieldsExtension(FieldsExtensionBase): + """Override the POST model""" + + POST = FieldsExtensionPostRequest + + +from stac_fastapi.api.app import StacApi + +stac = StacApi( + extensions=[ + FieldsExtension() + ] +) +``` + +## Pagination extension + +In stac-fastapi v3.0, we removed the `pagination_extension` attribute in `stac_fastapi.api.app.StacApi`. This attribute was used within the `register_get_item_collection` to update the request model for the `/collections/{collection_id}/items` endpoint. + +It's now up to the user to create the request model and use the `items_get_request_model=` attribute in the StacApi object. + +```python +# before +stac=StacApi( + pagination_extension=TokenPaginationExtension, + extension=[TokenPaginationExtension] +) + +# now +items_get_request_model = create_request_model( + "ItemCollectionURI", + base_model=ItemCollectionUri, + mixins=[TokenPaginationExtension().GET], +) + +stac=StacApi( + extension=[TokenPaginationExtension], + items_get_request_model=items_get_request_model, +) +``` diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 44a55764f..a03c5d108 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -18,18 +18,18 @@ from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware from stac_fastapi.api.models import ( + APIRequest, CollectionUri, EmptyRequest, GeoJSONResponse, ItemCollectionUri, ItemUri, - create_request_model, ) from stac_fastapi.api.openapi import update_openapi from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint # TODO: make this module not depend on `stac_fastapi.extensions` -from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension +from stac_fastapi.extensions.core import FieldsExtension from stac_fastapi.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -108,7 +108,10 @@ class StacApi: search_post_request_model: Type[BaseSearchPostRequest] = attr.ib( default=BaseSearchPostRequest ) - pagination_extension = attr.ib(default=TokenPaginationExtension) + collections_get_request_model: Type[APIRequest] = attr.ib(default=EmptyRequest) + collection_get_request_model: Type[APIRequest] = attr.ib(default=CollectionUri) + items_get_request_model: Type[APIRequest] = attr.ib(default=ItemCollectionUri) + item_get_request_model: Type[APIRequest] = attr.ib(default=ItemUri) response_class: Type[Response] = attr.ib(default=JSONResponse) middlewares: List[Middleware] = attr.ib( default=attr.Factory( @@ -211,7 +214,9 @@ def register_get_item(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.get_item, ItemUri), + endpoint=create_async_endpoint( + self.client.get_item, self.item_get_request_model + ), ) def register_post_search(self): @@ -302,7 +307,9 @@ def register_get_collections(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest), + endpoint=create_async_endpoint( + self.client.all_collections, self.collections_get_request_model + ), ) def register_get_collection(self): @@ -329,7 +336,9 @@ def register_get_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.get_collection, CollectionUri), + endpoint=create_async_endpoint( + self.client.get_collection, self.collection_get_request_model + ), ) def register_get_item_collection(self): @@ -338,16 +347,6 @@ def register_get_item_collection(self): Returns: None """ - pagination_extension = self.get_extension(self.pagination_extension) - if pagination_extension is not None: - mixins = [pagination_extension.GET] - else: - mixins = None - request_model = create_request_model( - "ItemCollectionURI", - base_model=ItemCollectionUri, - mixins=mixins, - ) self.router.add_api_route( name="Get ItemCollection", path="/collections/{collection_id}/items", @@ -366,7 +365,9 @@ def register_get_item_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.item_collection, request_model), + endpoint=create_async_endpoint( + self.client.item_collection, self.items_get_request_model + ), ) def register_core(self): diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 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 829982b51..1076c24e1 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,13 +1,19 @@ +from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Union import pytest +from fastapi import Path, Query from fastapi.testclient import TestClient from pydantic import ValidationError from stac_pydantic import api from stac_fastapi.api import app -from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.api.models import ( + APIRequest, + create_get_request_model, + create_post_request_model, +) from stac_fastapi.extensions.core import FieldsExtension, FilterExtension from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings @@ -294,3 +300,66 @@ def item_collection( else: assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + + +def test_request_model(AsyncTestCoreClient): + """Test if request models are passed correctly.""" + + @dataclass + class CollectionsRequest(APIRequest): + user: str = Query(...) + + @dataclass + class CollectionRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + user: str = Query(...) + + @dataclass + class ItemsRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + user: str = Query(...) + + @dataclass + class ItemRequest(APIRequest): + collection_id: str = Path(description="Collection ID") + item_id: str = Path(description="Item ID") + user: str = Query(...) + + test_app = app.StacApi( + settings=ApiSettings(), + client=AsyncTestCoreClient(), + collections_get_request_model=CollectionsRequest, + collection_get_request_model=CollectionRequest, + items_get_request_model=ItemsRequest, + item_get_request_model=ItemRequest, + extensions=[], + ) + + with TestClient(test_app.app) as client: + resp = client.get("/collections") + assert resp.status_code == 400 + + resp = client.get("/collections", params={"user": "Luke"}) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection") + assert resp.status_code == 400 + + resp = client.get("/collections/test_collection", params={"user": "Leia"}) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection/items") + assert resp.status_code == 400 + + resp = client.get( + "/collections/test_collection/items", params={"user": "Obi-Wan"} + ) + assert resp.status_code == 200 + + resp = client.get("/collections/test_collection/items/test_item") + assert resp.status_code == 400 + + resp = client.get( + "/collections/test_collection/items/test_item", params={"user": "Chewbacca"} + ) + assert resp.status_code == 200 From b3e7cd0c53d55a64bc4822703bbdac4f065a38e0 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 2 Jul 2024 22:55:29 +0200 Subject: [PATCH 51/57] replace attr with dataclass + fastapi.Query() for GET models (#714) * demonstrate issue 713 * move from attr to dataclass+fastapi.Query() for GET models * update migration --- CHANGES.md | 1 + docs/src/migrations/v3.0.0.md | 44 +++++++++++++++++- stac_fastapi/api/stac_fastapi/api/models.py | 40 ++++++++++------ stac_fastapi/api/tests/test_models.py | 30 ++++++++++-- .../extensions/core/aggregation/request.py | 17 +++++-- .../extensions/core/fields/request.py | 13 ++++-- .../extensions/core/filter/request.py | 12 +++-- .../extensions/core/pagination/request.py | 12 +++-- .../extensions/core/query/request.py | 8 ++-- .../extensions/core/sort/request.py | 13 ++++-- .../extensions/core/transaction.py | 14 +++--- .../extensions/tests/test_aggregation.py | 32 +++++++++++++ stac_fastapi/extensions/tests/test_filter.py | 46 ++++++++++++++++++- .../types/stac_fastapi/types/search.py | 30 ++++++++---- 14 files changed, 253 insertions(+), 59 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index fc4a11e9e..df7ae0d3c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,7 @@ ### Changed +* Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714) * Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) ## [3.0.0a4] - 2024-06-27 diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 6cbb3605a..8bc86f940 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -13,7 +13,6 @@ Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pyda In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21) - ## Deprecation * the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification. @@ -24,6 +23,49 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma * `PostFieldsExtension.filter_fields` property has been removed. +## `attr` -> `dataclass` for APIRequest models + +Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`. + +```python +# before +@attr.s +class CollectionModel(APIRequest): + collections: Optional[str] = attr.ib(default=None, converter=str2list) + +# now +@dataclass +class CollectionModel(APIRequest): + collections: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.collections: + self.collections = str2list(self.collections) # type: ignore + +``` + +!!! warning + + if you want to extend a class with a `required` attribute (without default), you will have to write all the attributes to avoid having *non-default* attributes defined after *default* attributes (ref: https://github.com/stac-utils/stac-fastapi/pull/714/files#r1651557338) + + ```python + @dataclass + class A: + value: Annotated[str, Query()] + + # THIS WON'T WORK + @dataclass + class B(A): + another_value: Annotated[str, Query(...)] + + # DO THIS + @dataclass + class B(A): + another_value: Annotated[str, Query(...)] + value: Annotated[str, Query()] + ``` + ## Middlewares configuration The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 307be14a7..7a39fe49a 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,12 +1,13 @@ """Api request/response models.""" import importlib.util +from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union -import attr -from fastapi import Path +from fastapi import Path, Query from pydantic import BaseModel, create_model from stac_pydantic.shared import BBox +from typing_extensions import Annotated from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.rfc3339 import DateTimeType @@ -37,11 +38,11 @@ def create_request_model( mixins = mixins or [] - models = [base_model] + extension_models + mixins + models = extension_models + mixins + [base_model] # Handle GET requests if all([issubclass(m, APIRequest) for m in models]): - return attr.make_class(model_name, attrs={}, bases=tuple(models)) + return make_dataclass(model_name, [], bases=tuple(models)) # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): @@ -80,34 +81,43 @@ def create_post_request_model( ) -@attr.s # type:ignore +@dataclass class CollectionUri(APIRequest): """Get or delete collection.""" - collection_id: str = attr.ib(default=Path(..., description="Collection ID")) + collection_id: Annotated[str, Path(description="Collection ID")] -@attr.s -class ItemUri(CollectionUri): +@dataclass +class ItemUri(APIRequest): """Get or delete item.""" - item_id: str = attr.ib(default=Path(..., description="Item ID")) + collection_id: Annotated[str, Path(description="Collection ID")] + item_id: Annotated[str, Path(description="Item ID")] -@attr.s +@dataclass class EmptyRequest(APIRequest): """Empty request.""" ... -@attr.s -class ItemCollectionUri(CollectionUri): +@dataclass +class ItemCollectionUri(APIRequest): """Get item collection.""" - limit: int = attr.ib(default=10) - bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) + collection_id: Annotated[str, Path(description="Collection ID")] + limit: Annotated[int, Query()] = 10 + bbox: Annotated[Optional[BBox], Query()] = None + datetime: Annotated[Optional[DateTimeType], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.bbox: + self.bbox = str2bbox(self.bbox) # type: ignore + if self.datetime: + self.datetime = str_to_interval(self.datetime) # type: ignore # Test for ORJSON and use it rather than stdlib JSON where supported diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index cbff0f53d..24ed59a18 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -1,6 +1,8 @@ import json import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient from pydantic import ValidationError from stac_fastapi.api.models import create_get_request_model, create_post_request_model @@ -26,13 +28,33 @@ def test_create_get_request_model(): datetime="2020-01-01T00:00:00Z", limit=10, filter="test==test", - # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638 - # hyphen aliases are not properly working - # **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"}, + filter_crs="epsg:4326", + filter_lang="cql2-text", ) assert model.collections == ["test1", "test2"] - # assert model.filter_crs == "epsg:4326" + assert model.filter_crs == "epsg:4326" + + app = FastAPI() + + @app.get("/test") + def route(model=Depends(request_model)): + return model + + with TestClient(app) as client: + resp = client.get( + "/test", + params={ + "collections": "test1,test2", + "filter-crs": "epsg:4326", + "filter-lang": "cql2-text", + }, + ) + assert resp.status_code == 200 + response_dict = resp.json() + assert response_dict["collections"] == ["test1", "test2"] + assert response_dict["filter_crs"] == "epsg:4326" + assert response_dict["filter_lang"] == "cql2-text" @pytest.mark.parametrize( diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 08ebe0cfc..325fc55ee 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,8 +1,11 @@ """Request model for the Aggregation extension.""" +from dataclasses import dataclass from typing import List, Optional -import attr +from fastapi import Query +from pydantic import Field +from typing_extensions import Annotated from stac_fastapi.types.search import ( BaseSearchGetRequest, @@ -11,14 +14,20 @@ ) -@attr.s +@dataclass class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Optional[str] = attr.ib(default=None, converter=str2list) + aggregations: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + super().__post_init__() + if self.aggregations: + self.aggregations = str2list(self.aggregations) # type: ignore class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[List[str]] = attr.ib(default=None) + aggregations: Optional[List[str]] = Field(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index e08572ca0..a77539c0b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,10 +1,12 @@ """Request models for the fields extension.""" import warnings +from dataclasses import dataclass from typing import Dict, Optional, Set -import attr +from fastapi import Query from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list @@ -68,11 +70,16 @@ def filter_fields(self) -> Dict: } -@attr.s +@dataclass class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Optional[str] = attr.ib(default=None, converter=str2list) + fields: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.fields: + self.fields = str2list(self.fields) # type: ignore class FieldsExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 35a17bf36..970804b6d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,22 +1,24 @@ """Filter extension request models.""" +from dataclasses import dataclass from typing import Any, Dict, Literal, Optional -import attr +from fastapi import Query from pydantic import BaseModel, Field +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] -@attr.s +@dataclass class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Optional[str] = attr.ib(default=None) - filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-text") + filter: Annotated[Optional[str], Query()] = None + filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = None + filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = "cql2-text" class FilterExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py index 9524ee324..94d98df65 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -1,18 +1,20 @@ """Pagination extension request models.""" +from dataclasses import dataclass from typing import Optional -import attr +from fastapi import Query from pydantic import BaseModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest -@attr.s +@dataclass class GETTokenPagination(APIRequest): """Token pagination for GET requests.""" - token: Optional[str] = attr.ib(default=None) + token: Annotated[Optional[str], Query()] = None class POSTTokenPagination(BaseModel): @@ -21,11 +23,11 @@ class POSTTokenPagination(BaseModel): token: Optional[str] = None -@attr.s +@dataclass class GETPagination(APIRequest): """Page based pagination for GET requests.""" - page: Optional[str] = attr.ib(default=None) + page: Annotated[Optional[str], Query()] = None class POSTPagination(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index 7f8425e70..d431b0dea 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -1,18 +1,20 @@ """Request model for the Query extension.""" +from dataclasses import dataclass from typing import Any, Dict, Optional -import attr +from fastapi import Query from pydantic import BaseModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest -@attr.s +@dataclass class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Optional[str] = attr.ib(default=None) + query: Annotated[Optional[str], Query()] = None class QueryExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 377067ff9..7165d2e31 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,20 +1,27 @@ # encoding: utf-8 """Request model for the Sort Extension.""" +from dataclasses import dataclass from typing import List, Optional -import attr +from fastapi import Query from pydantic import BaseModel from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel +from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list -@attr.s +@dataclass class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Optional[str] = attr.ib(default=None, converter=str2list) + sortby: Annotated[Optional[str], Query()] = None + + def __post_init__(self): + """convert attributes.""" + if self.sortby: + self.sortby = str2list(self.sortby) # type: ignore class SortExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index a1c2391f6..27f2291d1 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,5 +1,6 @@ """Transaction extension.""" +from dataclasses import dataclass from typing import List, Optional, Type, Union import attr @@ -7,6 +8,7 @@ from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.shared import MimeTypes from starlette.responses import JSONResponse, Response +from typing_extensions import Annotated from stac_fastapi.api.models import CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint @@ -15,25 +17,25 @@ from stac_fastapi.types.extension import ApiExtension -@attr.s +@dataclass class PostItem(CollectionUri): """Create Item.""" - item: Union[Item, ItemCollection] = attr.ib(default=Body(None)) + item: Annotated[Union[Item, ItemCollection], Body()] = None -@attr.s +@dataclass class PutItem(ItemUri): """Update Item.""" - item: Item = attr.ib(default=Body(None)) + item: Annotated[Item, Body()] = None -@attr.s +@dataclass class PutCollection(CollectionUri): """Update Collection.""" - collection: Collection = attr.ib(default=Body(None)) + collection: Annotated[Collection, Body()] = None @attr.s diff --git a/stac_fastapi/extensions/tests/test_aggregation.py b/stac_fastapi/extensions/tests/test_aggregation.py index 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_filter.py b/stac_fastapi/extensions/tests/test_filter.py index ca72dc51a..a13fb14c9 100644 --- a/stac_fastapi/extensions/tests/test_filter.py +++ b/stac_fastapi/extensions/tests/test_filter.py @@ -21,7 +21,8 @@ def get_item(self, *args, **kwargs): raise NotImplementedError def get_search(self, *args, **kwargs): - raise NotImplementedError + _ = kwargs.pop("request", None) + return kwargs def post_search(self, *args, **kwargs): return args[0].model_dump() @@ -73,3 +74,46 @@ def test_search_filter_post_filter_lang_non_default(client: TestClient): assert response.is_success, response.json() response_dict = response.json() assert response_dict["filter_lang"] == filter_lang_value + + +def test_search_filter_get(client: TestClient): + """Test search GET endpoint with filter ext.""" + response = client.get( + "/search", + params={ + "filter": "id='item_id' AND collection='collection_id'", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert not response_dict["collections"] + assert response_dict["filter"] == "id='item_id' AND collection='collection_id'" + assert not response_dict["filter_crs"] + assert response_dict["filter_lang"] == "cql2-text" + + response = client.get( + "/search", + params={ + "filter": {"op": "=", "args": [{"property": "id"}, "test-item"]}, + "filter-lang": "cql2-json", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert not response_dict["collections"] + assert ( + response_dict["filter"] + == "{'op': '=', 'args': [{'property': 'id'}, 'test-item']}" + ) + assert not response_dict["filter_crs"] + assert response_dict["filter_lang"] == "cql2-json" + + response = client.get( + "/search", + params={ + "collections": "collection1,collection2", + }, + ) + assert response.is_success, response.json() + response_dict = response.json() + assert response_dict["collections"] == ["collection1", "collection2"] diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cf6647340..649a1a8ef 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -3,9 +3,10 @@ """ import abc +from dataclasses import dataclass from typing import Dict, List, Optional, Union -import attr +from fastapi import Query from pydantic import PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search @@ -42,7 +43,7 @@ def str2bbox(x: str) -> Optional[BBox]: Limit = Annotated[PositiveInt, AfterValidator(crop)] -@attr.s # type:ignore +@dataclass class APIRequest(abc.ABC): """Generic API Request base class.""" @@ -52,16 +53,27 @@ def kwargs(self) -> Dict: return self.__dict__ -@attr.s +@dataclass class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Optional[str] = attr.ib(default=None, converter=str2list) - ids: Optional[str] = attr.ib(default=None, converter=str2list) - bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - intersects: Optional[str] = attr.ib(default=None) - datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) - limit: Optional[int] = attr.ib(default=10) + collections: Annotated[Optional[str], Query()] = None + ids: Annotated[Optional[str], Query()] = None + bbox: Annotated[Optional[BBox], Query()] = None + intersects: Annotated[Optional[str], Query()] = None + datetime: Annotated[Optional[DateTimeType], Query()] = None + limit: Annotated[Optional[int], Query()] = 10 + + def __post_init__(self): + """convert attributes.""" + if self.collections: + self.collections = str2list(self.collections) # type: ignore + if self.ids: + self.ids = str2list(self.ids) # type: ignore + if self.bbox: + self.bbox = str2bbox(self.bbox) # type: ignore + if self.datetime: + self.datetime = str_to_interval(self.datetime) # type: ignore class BaseSearchPostRequest(Search): From 3c58f0f6e8f26d111dd21f1ffce3442b45b54a70 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 5 Jul 2024 14:47:21 +0200 Subject: [PATCH 52/57] pin minimal version for fastapi-slim (#724) --- stac_fastapi/types/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 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", From dbd04643ba871d032ed5fdf214e4a5b0dc54131d Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Fri, 5 Jul 2024 14:58:16 +0200 Subject: [PATCH 53/57] remove FieldsExtension check in StacApi (#725) --- CHANGES.md | 1 + docs/src/migrations/v3.0.0.md | 64 +++++++++++++++++++++ stac_fastapi/api/stac_fastapi/api/app.py | 21 ++----- stac_fastapi/api/stac_fastapi/api/models.py | 35 ++++------- stac_fastapi/api/tests/test_app.py | 31 ++++++++-- 5 files changed, 110 insertions(+), 42 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index df7ae0d3c..22444ca5e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ * Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716) * Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi` * Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute) +* Removed use of `FieldsExtension` in `stac_fastapi.api.app.StacApi`. If users use `FieldsExtension`, they would have to handle overpassing the model validation step by returning a `JSONResponse` from the `post_search` and `get_search` client methods. ### Changed diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 8bc86f940..0cb66653a 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -203,3 +203,67 @@ stac=StacApi( items_get_request_model=items_get_request_model, ) ``` + + +## Fields extension and model validation + +When using the `Fields` extension, the `/search` endpoint should be able to return `**invalid** STAC Items. This creates an issue when *model validation* is enabled at the application level. + +Previously when adding the `FieldsExtension` to the extensions list and if setting output model validation, we were turning off the validation for both GET/POST `/search` endpoints. This was by-passing validation even when users were not using the `fields` options in requests. + +In `stac-fastapi` v3.0, implementers will have to by-pass the *validation step* at `Client` level by returning `JSONResponse` from the `post_search` and `get_search` client methods. + +```python +# before +class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + return {"not": "a proper stac item"} + +# now +class BadCoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac.ItemCollection: + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if getattr(search_request, "fields", None): + return JSONResponse(content=resp) + + return resp + + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.ItemCollection: + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if "fields" in kwargs: + return JSONResponse(content=resp) + + return resp + +``` diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index a03c5d108..5148f2baf 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -27,9 +27,6 @@ ) from stac_fastapi.api.openapi import update_openapi from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint - -# TODO: make this module not depend on `stac_fastapi.extensions` -from stac_fastapi.extensions.core import FieldsExtension from stac_fastapi.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -225,15 +222,12 @@ def register_post_search(self): Returns: None """ - fields_ext = self.get_extension(FieldsExtension) self.router.add_api_route( name="Search", path="/search", - response_model=( - (api.ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None - ), + response_model=api.ItemCollection + if self.settings.enable_response_models + else None, responses={ 200: { "content": { @@ -257,15 +251,12 @@ def register_get_search(self): Returns: None """ - fields_ext = self.get_extension(FieldsExtension) self.router.add_api_route( name="Search", path="/search", - response_model=( - (api.ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None - ), + response_model=api.ItemCollection + if self.settings.enable_response_models + else None, responses={ 200: { "content": { diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 7a39fe49a..1c2146d44 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,6 +1,5 @@ """Api request/response models.""" -import importlib.util from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union @@ -19,6 +18,12 @@ str_to_interval, ) +try: + import orjson # noqa + from fastapi.responses import ORJSONResponse as JSONResponse +except ImportError: # pragma: nocover + from starlette.responses import JSONResponse + def create_request_model( model_name="SearchGetRequest", @@ -120,29 +125,13 @@ def __post_init__(self): self.datetime = str_to_interval(self.datetime) # type: ignore -# Test for ORJSON and use it rather than stdlib JSON where supported -if importlib.util.find_spec("orjson") is not None: - from fastapi.responses import ORJSONResponse - - class GeoJSONResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/geo+json" - - class JSONSchemaResponse(ORJSONResponse): - """JSON with custom, vendor content-type.""" - - media_type = "application/schema+json" - -else: - from starlette.responses import JSONResponse +class GeoJSONResponse(JSONResponse): + """JSON with custom, vendor content-type.""" - class GeoJSONResponse(JSONResponse): - """JSON with custom, vendor content-type.""" + media_type = "application/geo+json" - media_type = "application/geo+json" - class JSONSchemaResponse(JSONResponse): - """JSON with custom, vendor content-type.""" +class JSONSchemaResponse(JSONResponse): + """JSON with custom, vendor content-type.""" - media_type = "application/schema+json" + media_type = "application/schema+json" diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 1076c24e1..9fb2c52e0 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -11,6 +11,7 @@ from stac_fastapi.api import app from stac_fastapi.api.models import ( APIRequest, + JSONResponse, create_get_request_model, create_post_request_model, ) @@ -206,7 +207,14 @@ class BadCoreClient(BaseCoreClient): def post_search( self, search_request: BaseSearchPostRequest, **kwargs ) -> stac.ItemCollection: - return {"not": "a proper stac item"} + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if getattr(search_request, "fields", None): + return JSONResponse(content=resp) + + return resp def get_search( self, @@ -218,7 +226,14 @@ def get_search( limit: Optional[int] = 10, **kwargs, ) -> stac.ItemCollection: - return {"not": "a proper stac item"} + resp = {"not": "a proper stac item"} + + # if `fields` extension is enabled, then we return a JSONResponse + # to avoid Item validation + if "fields" in kwargs: + return JSONResponse(content=resp) + + return resp def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: raise NotImplementedError @@ -240,6 +255,7 @@ def item_collection( ) -> stac.ItemCollection: raise NotImplementedError + # With FieldsExtension test_app = app.StacApi( settings=ApiSettings(enable_response_models=validate), client=BadCoreClient(), @@ -264,14 +280,18 @@ def item_collection( }, ) + # With or without validation, /search endpoints will always return 200 + # because we have the `FieldsExtension` enabled, so the endpoint + # will avoid the model validation (by returning JSONResponse) assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + # Without FieldsExtension test_app = app.StacApi( settings=ApiSettings(enable_response_models=validate), client=BadCoreClient(), - search_get_request_model=create_get_request_model([FieldsExtension()]), - search_post_request_model=create_post_request_model([FieldsExtension()]), + search_get_request_model=create_get_request_model([]), + search_post_request_model=create_post_request_model([]), extensions=[], ) @@ -290,7 +310,10 @@ def item_collection( }, }, ) + if validate: + # NOTE: the `fields` options will be ignored by fastAPI because it's + # not part of the request model, so the client should not by-pass the validation assert get_search.status_code == 500, ( get_search.json()["code"] == "ResponseValidationError" ) From 494e485c5d619d8c800bc845c391dfdced21fb2a Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Mon, 8 Jul 2024 10:05:05 +0200 Subject: [PATCH 54/57] Release/v3.0.0b1 (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update changelog * Bump version: 3.0.0a4 → 3.0.0b1 --- CHANGES.md | 6 +++++- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 22444ca5e..d19e1607f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +## [3.0.0b1] - 2024-07-05 + ### Added * Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for: @@ -21,6 +23,7 @@ * Replaced `@attrs` with python `@dataclass` for `APIRequest` (model for GET request) class type [#714](https://github.com/stac-utils/stac-fastapi/pull/714) * Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717) +* update FastAPI requirement to `>=0.111.0` ## [3.0.0a4] - 2024-06-27 @@ -429,7 +432,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0b1]: [3.0.0a4]: [3.0.0a3]: [3.0.0a2]: diff --git a/VERSION b/VERSION index 255dd065c..2daa89b06 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0a4 +3.0.0b1 diff --git a/pyproject.toml b/pyproject.toml index fbe51fbf2..d7bffeaa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0a4" +current_version = "3.0.0b1" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index f182485f7..171dc64bf 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index f182485f7..171dc64bf 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index f182485f7..171dc64bf 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0a4" +__version__ = "3.0.0b1" From 0885f0b401c4831fd26a4137238eb7bbaa5c2b3a Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 9 Jul 2024 17:04:45 +0200 Subject: [PATCH 55/57] move back to attrs (#729) * move back to attrs * update changelog * edit tests * more doc --- CHANGES.md | 9 ++- docs/src/migrations/v3.0.0.md | 78 ++++++++----------- stac_fastapi/api/stac_fastapi/api/models.py | 37 ++++----- stac_fastapi/api/tests/test_app.py | 27 +++---- stac_fastapi/api/tests/test_models.py | 26 ++++--- .../extensions/core/aggregation/request.py | 14 ++-- .../extensions/core/fields/request.py | 11 +-- .../extensions/core/filter/request.py | 14 ++-- .../extensions/core/pagination/request.py | 10 +-- .../extensions/core/query/request.py | 6 +- .../extensions/core/sort/request.py | 12 +-- .../extensions/core/transaction.py | 13 ++-- .../types/stac_fastapi/types/search.py | 36 ++++----- 13 files changed, 136 insertions(+), 157 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d19e1607f..649bd2edf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## [Unreleased] - TBD +## [3.0.0b2] - 2024-07-09 + +### Changed + +* move back to `@attrs` (instead of dataclass) for `APIRequest` (model for GET request) class type [#729](https://github.com/stac-utils/stac-fastapi/pull/729) + ## [3.0.0b1] - 2024-07-05 ### Added @@ -432,7 +438,8 @@ * First PyPi release! -[Unreleased]: +[Unreleased]: +[3.0.0b2]: [3.0.0b1]: [3.0.0a4]: [3.0.0a3]: diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index 0cb66653a..e9b2ee649 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -23,49 +23,6 @@ In addition to pydantic v2 update, `stac-pydantic` has been updated to better ma * `PostFieldsExtension.filter_fields` property has been removed. -## `attr` -> `dataclass` for APIRequest models - -Models for **GET** requests, defining the path and query parameters, now uses python `dataclass` instead of `attr`. - -```python -# before -@attr.s -class CollectionModel(APIRequest): - collections: Optional[str] = attr.ib(default=None, converter=str2list) - -# now -@dataclass -class CollectionModel(APIRequest): - collections: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.collections: - self.collections = str2list(self.collections) # type: ignore - -``` - -!!! warning - - if you want to extend a class with a `required` attribute (without default), you will have to write all the attributes to avoid having *non-default* attributes defined after *default* attributes (ref: https://github.com/stac-utils/stac-fastapi/pull/714/files#r1651557338) - - ```python - @dataclass - class A: - value: Annotated[str, Query()] - - # THIS WON'T WORK - @dataclass - class B(A): - another_value: Annotated[str, Query(...)] - - # DO THIS - @dataclass - class B(A): - another_value: Annotated[str, Query(...)] - value: Annotated[str, Query()] - ``` - ## Middlewares configuration The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442). @@ -113,9 +70,9 @@ stac = StacApi( ) # now -@dataclass +@attr.s class CollectionsRequest(APIRequest): - user: str = Query(...) + user: Annotated[str, Query(...)] = attr.ib() stac = StacApi( search_get_request_model=getSearchModel, @@ -127,6 +84,37 @@ stac = StacApi( ) ``` +## APIRequest - GET Request Model + +Most of the **GET** endpoints are configured with `stac_fastapi.types.search.APIRequest` base class. + +e.g the BaseSearchGetRequest, default for the `GET - /search` endpoint: + +```python +@attr.s +class BaseSearchGetRequest(APIRequest): + """Base arguments for GET Request.""" + + collections: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) + ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) + limit: Annotated[Optional[int], Query()] = attr.ib(default=10) +``` + +We use [*python attrs*](https://www.attrs.org/en/stable/) to construct those classes. **Type Hint** for each attribute is important and should be defined using `Annotated[{type}, fastapi.Query()]` form. + +```python +@attr.s +class SomeRequest(APIRequest): + user_number: Annotated[Optional[int], Query(alias="user-number")] = attr.ib(default=None) +``` + ## Filter extension `default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 1c2146d44..737089c2d 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,8 +1,8 @@ """Api request/response models.""" -from dataclasses import dataclass, make_dataclass from typing import List, Optional, Type, Union +import attr from fastapi import Path, Query from pydantic import BaseModel, create_model from stac_pydantic.shared import BBox @@ -43,11 +43,11 @@ def create_request_model( mixins = mixins or [] - models = extension_models + mixins + [base_model] + models = [base_model] + extension_models + mixins # Handle GET requests if all([issubclass(m, APIRequest) for m in models]): - return make_dataclass(model_name, [], bases=tuple(models)) + return attr.make_class(model_name, attrs={}, bases=tuple(models)) # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): @@ -86,43 +86,38 @@ def create_post_request_model( ) -@dataclass +@attr.s class CollectionUri(APIRequest): """Get or delete collection.""" - collection_id: Annotated[str, Path(description="Collection ID")] + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() -@dataclass +@attr.s class ItemUri(APIRequest): """Get or delete item.""" - collection_id: Annotated[str, Path(description="Collection ID")] - item_id: Annotated[str, Path(description="Item ID")] + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() -@dataclass +@attr.s class EmptyRequest(APIRequest): """Empty request.""" ... -@dataclass +@attr.s class ItemCollectionUri(APIRequest): """Get item collection.""" - collection_id: Annotated[str, Path(description="Collection ID")] - limit: Annotated[int, Query()] = 10 - bbox: Annotated[Optional[BBox], Query()] = None - datetime: Annotated[Optional[DateTimeType], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.bbox: - self.bbox = str2bbox(self.bbox) # type: ignore - if self.datetime: - self.datetime = str_to_interval(self.datetime) # type: ignore + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + limit: Annotated[int, Query()] = attr.ib(default=10) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) class GeoJSONResponse(JSONResponse): diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 9fb2c52e0..0ddcb2429 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,12 +1,13 @@ -from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Union +import attr import pytest from fastapi import Path, Query from fastapi.testclient import TestClient from pydantic import ValidationError from stac_pydantic import api +from typing_extensions import Annotated from stac_fastapi.api import app from stac_fastapi.api.models import ( @@ -328,25 +329,25 @@ def item_collection( def test_request_model(AsyncTestCoreClient): """Test if request models are passed correctly.""" - @dataclass + @attr.s class CollectionsRequest(APIRequest): - user: str = Query(...) + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class CollectionRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class ItemsRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() - @dataclass + @attr.s class ItemRequest(APIRequest): - collection_id: str = Path(description="Collection ID") - item_id: str = Path(description="Item ID") - user: str = Query(...) + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() + user: Annotated[str, Query(...)] = attr.ib() test_app = app.StacApi( settings=ApiSettings(), diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index 24ed59a18..b0c2ad90e 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -1,19 +1,20 @@ import json import pytest -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.testclient import TestClient from pydantic import ValidationError from stac_fastapi.api.models import create_get_request_model, create_post_request_model -from stac_fastapi.extensions.core.filter.filter import FilterExtension -from stac_fastapi.extensions.core.sort.sort import SortExtension +from stac_fastapi.extensions.core import FieldsExtension, FilterExtension, SortExtension from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest def test_create_get_request_model(): - extensions = [FilterExtension()] - request_model = create_get_request_model(extensions, BaseSearchGetRequest) + request_model = create_get_request_model( + extensions=[FilterExtension(), FieldsExtension()], + base_model=BaseSearchGetRequest, + ) model = request_model( collections="test1,test2", @@ -35,6 +36,9 @@ def test_create_get_request_model(): assert model.collections == ["test1", "test2"] assert model.filter_crs == "epsg:4326" + with pytest.raises(HTTPException): + request_model(datetime="yo") + app = FastAPI() @app.get("/test") @@ -62,8 +66,10 @@ def route(model=Depends(request_model)): [(None, True), ({"test": "test"}, True), ("test==test", False), ([], False)], ) def test_create_post_request_model(filter, passes): - extensions = [FilterExtension()] - request_model = create_post_request_model(extensions, BaseSearchPostRequest) + request_model = create_post_request_model( + extensions=[FilterExtension(), FieldsExtension()], + base_model=BaseSearchPostRequest, + ) if not passes: with pytest.raises(ValidationError): @@ -100,8 +106,10 @@ def test_create_post_request_model(filter, passes): ], ) def test_create_post_request_model_nested_fields(sortby, passes): - extensions = [SortExtension()] - request_model = create_post_request_model(extensions, BaseSearchPostRequest) + request_model = create_post_request_model( + extensions=[SortExtension()], + base_model=BaseSearchPostRequest, + ) if not passes: with pytest.raises(ValidationError): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 325fc55ee..1f4b6a93b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -1,8 +1,8 @@ """Request model for the Aggregation extension.""" -from dataclasses import dataclass from typing import List, Optional +import attr from fastapi import Query from pydantic import Field from typing_extensions import Annotated @@ -14,17 +14,13 @@ ) -@dataclass +@attr.s class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - super().__post_init__() - if self.aggregations: - self.aggregations = str2list(self.aggregations) # type: ignore + aggregations: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) class AggregationExtensionPostRequest(BaseSearchPostRequest): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index a77539c0b..e0c42a574 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,9 +1,9 @@ """Request models for the fields extension.""" import warnings -from dataclasses import dataclass from typing import Dict, Optional, Set +import attr from fastapi import Query from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -70,16 +70,11 @@ def filter_fields(self) -> Dict: } -@dataclass +@attr.s class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.fields: - self.fields = str2list(self.fields) # type: ignore + fields: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) class FieldsExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 970804b6d..917f5f086 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,8 +1,8 @@ """Filter extension request models.""" -from dataclasses import dataclass from typing import Any, Dict, Literal, Optional +import attr from fastapi import Query from pydantic import BaseModel, Field from typing_extensions import Annotated @@ -12,13 +12,17 @@ FilterLang = Literal["cql-json", "cql2-json", "cql2-text"] -@dataclass +@attr.s class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Annotated[Optional[str], Query()] = None - filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = None - filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = "cql2-text" + filter: Annotated[Optional[str], Query()] = attr.ib(default=None) + filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = attr.ib( + default=None + ) + filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = attr.ib( + default="cql2-text" + ) class FilterExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py index 94d98df65..66391c7f9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/request.py @@ -1,8 +1,8 @@ """Pagination extension request models.""" -from dataclasses import dataclass from typing import Optional +import attr from fastapi import Query from pydantic import BaseModel from typing_extensions import Annotated @@ -10,11 +10,11 @@ from stac_fastapi.types.search import APIRequest -@dataclass +@attr.s class GETTokenPagination(APIRequest): """Token pagination for GET requests.""" - token: Annotated[Optional[str], Query()] = None + token: Annotated[Optional[str], Query()] = attr.ib(default=None) class POSTTokenPagination(BaseModel): @@ -23,11 +23,11 @@ class POSTTokenPagination(BaseModel): token: Optional[str] = None -@dataclass +@attr.s class GETPagination(APIRequest): """Page based pagination for GET requests.""" - page: Annotated[Optional[str], Query()] = None + page: Annotated[Optional[str], Query()] = attr.ib(default=None) class POSTPagination(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index d431b0dea..5d403a677 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -1,8 +1,8 @@ """Request model for the Query extension.""" -from dataclasses import dataclass from typing import Any, Dict, Optional +import attr from fastapi import Query from pydantic import BaseModel from typing_extensions import Annotated @@ -10,11 +10,11 @@ from stac_fastapi.types.search import APIRequest -@dataclass +@attr.s class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Annotated[Optional[str], Query()] = None + query: Annotated[Optional[str], Query()] = attr.ib(default=None) class QueryExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 7165d2e31..8eeccba0c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,9 +1,8 @@ -# encoding: utf-8 """Request model for the Sort Extension.""" -from dataclasses import dataclass from typing import List, Optional +import attr from fastapi import Query from pydantic import BaseModel from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel @@ -12,16 +11,11 @@ from stac_fastapi.types.search import APIRequest, str2list -@dataclass +@attr.s class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Annotated[Optional[str], Query()] = None - - def __post_init__(self): - """convert attributes.""" - if self.sortby: - self.sortby = str2list(self.sortby) # type: ignore + sortby: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) class SortExtensionPostRequest(BaseModel): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 27f2291d1..4e940a0ea 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,6 +1,5 @@ """Transaction extension.""" -from dataclasses import dataclass from typing import List, Optional, Type, Union import attr @@ -17,25 +16,25 @@ from stac_fastapi.types.extension import ApiExtension -@dataclass +@attr.s class PostItem(CollectionUri): """Create Item.""" - item: Annotated[Union[Item, ItemCollection], Body()] = None + item: Annotated[Union[Item, ItemCollection], Body()] = attr.ib(default=None) -@dataclass +@attr.s class PutItem(ItemUri): """Update Item.""" - item: Annotated[Item, Body()] = None + item: Annotated[Item, Body()] = attr.ib(default=None) -@dataclass +@attr.s class PutCollection(CollectionUri): """Update Collection.""" - collection: Annotated[Collection, Body()] = None + collection: Annotated[Collection, Body()] = attr.ib(default=None) @attr.s diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 649a1a8ef..b8ae23c86 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -2,10 +2,9 @@ """ -import abc -from dataclasses import dataclass from typing import Dict, List, Optional, Union +import attr from fastapi import Query from pydantic import PositiveInt from pydantic.functional_validators import AfterValidator @@ -43,8 +42,8 @@ def str2bbox(x: str) -> Optional[BBox]: Limit = Annotated[PositiveInt, AfterValidator(crop)] -@dataclass -class APIRequest(abc.ABC): +@attr.s +class APIRequest: """Generic API Request base class.""" def kwargs(self) -> Dict: @@ -53,27 +52,20 @@ def kwargs(self) -> Dict: return self.__dict__ -@dataclass +@attr.s class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = None - ids: Annotated[Optional[str], Query()] = None - bbox: Annotated[Optional[BBox], Query()] = None - intersects: Annotated[Optional[str], Query()] = None - datetime: Annotated[Optional[DateTimeType], Query()] = None - limit: Annotated[Optional[int], Query()] = 10 - - def __post_init__(self): - """convert attributes.""" - if self.collections: - self.collections = str2list(self.collections) # type: ignore - if self.ids: - self.ids = str2list(self.ids) # type: ignore - if self.bbox: - self.bbox = str2bbox(self.bbox) # type: ignore - if self.datetime: - self.datetime = str_to_interval(self.datetime) # type: ignore + collections: Annotated[Optional[str], Query()] = attr.ib( + default=None, converter=str2list + ) + ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) + datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( + default=None, converter=str_to_interval + ) + limit: Annotated[Optional[int], Query()] = attr.ib(default=10) class BaseSearchPostRequest(Search): From 599742c1d4a88645a6db4c6d9b49b2323400d246 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Tue, 9 Jul 2024 18:26:11 +0200 Subject: [PATCH 56/57] =?UTF-8?q?Bump=20version:=203.0.0b1=20=E2=86=92=203?= =?UTF-8?q?.0.0b2=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- pyproject.toml | 2 +- stac_fastapi/api/stac_fastapi/api/version.py | 2 +- stac_fastapi/extensions/stac_fastapi/extensions/version.py | 2 +- stac_fastapi/types/stac_fastapi/types/version.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 2daa89b06..2aa4d8f0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0b1 +3.0.0b2 diff --git a/pyproject.toml b/pyproject.toml index d7bffeaa7..9f4172999 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo quote-style = "double" [tool.bumpversion] -current_version = "3.0.0b1" +current_version = "3.0.0b2" parse = """(?x) (?P\\d+)\\. (?P\\d+)\\. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 171dc64bf..7296e8a98 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 171dc64bf..7296e8a98 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 171dc64bf..7296e8a98 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,2 @@ """Library version.""" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" From c55c2537728f4df49f5235cc49574f90f8c709b1 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Wed, 17 Jul 2024 11:09:34 +0200 Subject: [PATCH 57/57] add description and examples (#734) * add description and examples * update docs * update changelog * Update stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py --- CHANGES.md | 2 + docs/src/migrations/v3.0.0.md | 32 +++- stac_fastapi/api/stac_fastapi/api/models.py | 10 +- .../extensions/core/aggregation/request.py | 18 ++- .../extensions/core/fields/request.py | 23 ++- .../extensions/core/filter/request.py | 64 ++++++-- .../extensions/core/query/query.py | 2 +- .../extensions/core/query/request.py | 20 ++- .../extensions/core/sort/request.py | 31 +++- .../types/stac_fastapi/types/search.py | 138 ++++++++++++++++-- 10 files changed, 293 insertions(+), 47 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 649bd2edf..1cc1773c4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## [Unreleased] - TBD +* add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734) + ## [3.0.0b2] - 2024-07-09 ### Changed diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md index e9b2ee649..f781687c3 100644 --- a/docs/src/migrations/v3.0.0.md +++ b/docs/src/migrations/v3.0.0.md @@ -95,14 +95,12 @@ e.g the BaseSearchGetRequest, default for the `GET - /search` endpoint: class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list - ) - ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) + collections: Optional[List[str]] = attr.ib(default=None, converter=_collection_converter) + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) limit: Annotated[Optional[int], Query()] = attr.ib(default=10) ``` @@ -115,6 +113,26 @@ class SomeRequest(APIRequest): user_number: Annotated[Optional[int], Query(alias="user-number")] = attr.ib(default=None) ``` +Note: when an attribute has a `converter` (e.g `_ids_converter`), the **Type Hint** should be defined directly in the converter: + +```python +def _ids_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of Item ids to return.", + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + +@attr.s +class BaseSearchGetRequest(APIRequest): + """Base arguments for GET Request.""" + + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) +``` + ## Filter extension `default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706). diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 737089c2d..5a239b9f0 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -14,8 +14,8 @@ APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, - str2bbox, - str_to_interval, + _bbox_converter, + _datetime_converter, ) try: @@ -114,9 +114,9 @@ class ItemCollectionUri(APIRequest): collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() limit: Annotated[int, Query()] = attr.ib(default=10) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py index 1f4b6a93b..4e72e0005 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/request.py @@ -14,16 +14,26 @@ ) +def _agg_converter( + val: Annotated[ + Optional[str], + Query(description="A list of aggregations to compute and return."), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class AggregationExtensionGetRequest(BaseSearchGetRequest): """Aggregation Extension GET request model.""" - aggregations: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list - ) + aggregations: Optional[List[str]] = attr.ib(default=None, converter=_agg_converter) class AggregationExtensionPostRequest(BaseSearchPostRequest): """Aggregation Extension POST request model.""" - aggregations: Optional[List[str]] = Field(default=None) + aggregations: Optional[List[str]] = Field( + default=None, + description="A list of aggregations to compute and return.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index e0c42a574..d3737ea49 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,7 +1,7 @@ """Request models for the fields extension.""" import warnings -from typing import Dict, Optional, Set +from typing import Dict, List, Optional, Set import attr from fastapi import Query @@ -70,14 +70,31 @@ def filter_fields(self) -> Dict: } +def _fields_converter( + val: Annotated[ + Optional[str], + Query( + description="Include or exclude fields from items body.", + json_schema_extra={ + "example": "properties.datetime", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class FieldsExtensionGetRequest(APIRequest): """Additional fields for the GET request.""" - fields: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + fields: Optional[List[str]] = attr.ib(default=None, converter=_fields_converter) class FieldsExtensionPostRequest(BaseModel): """Additional fields and schema for the POST request.""" - fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension()) + fields: Optional[PostFieldsExtension] = Field( + PostFieldsExtension(), + description="Include or exclude fields from items body.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 917f5f086..30ac011b0 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -16,18 +16,62 @@ class FilterExtensionGetRequest(APIRequest): """Filter extension GET request model.""" - filter: Annotated[Optional[str], Query()] = attr.ib(default=None) - filter_crs: Annotated[Optional[str], Query(alias="filter-crs")] = attr.ib( - default=None - ) - filter_lang: Annotated[Optional[FilterLang], Query(alias="filter-lang")] = attr.ib( - default="cql2-text" - ) + filter: Annotated[ + Optional[str], + Query( + description="""A CQL filter expression for filtering items.\n +Supports `CQL-JSON` as defined in https://portal.ogc.org/files/96288\n +Remember to URL encode the CQL-JSON if using GET""", + json_schema_extra={ + "example": "id='LC08_L1TP_060247_20180905_20180912_01_T1_L1TP' AND collection='landsat8_l1tp'", # noqa: E501 + }, + ), + ] = attr.ib(default=None) + filter_crs: Annotated[ + Optional[str], + Query( + alias="filter-crs", + description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 + ), + ] = attr.ib(default=None) + filter_lang: Annotated[ + Optional[FilterLang], + Query( + alias="filter-lang", + description="The CQL filter encoding that the 'filter' value uses.", + ), + ] = attr.ib(default="cql2-text") class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" - filter: Optional[Dict[str, Any]] = None - filter_crs: Optional[str] = Field(alias="filter-crs", default=None) - filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql2-json") + filter: Optional[Dict[str, Any]] = Field( + default=None, + description="A CQL filter expression for filtering items.", + json_schema_extra={ + "example": { + "op": "and", + "args": [ + { + "op": "=", + "args": [ + {"property": "id"}, + "LC08_L1TP_060247_20180905_20180912_01_T1_L1TP", + ], + }, + {"op": "=", "args": [{"property": "collection"}, "landsat8_l1tp"]}, + ], + }, + }, + ) + filter_crs: Optional[str] = Field( + alias="filter-crs", + default=None, + description="The coordinate reference system (CRS) used by spatial literals in the 'filter' value. Default is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`", # noqa: E501 + ) + filter_lang: Optional[FilterLang] = Field( + alias="filter-lang", + default="cql2-json", + description="The CQL filter encoding that the 'filter' value uses.", + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py index 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 5d403a677..ad7f461c3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -4,7 +4,7 @@ import attr from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest @@ -14,10 +14,24 @@ class QueryExtensionGetRequest(APIRequest): """Query Extension GET request model.""" - query: Annotated[Optional[str], Query()] = attr.ib(default=None) + query: Annotated[ + Optional[str], + Query( + description="Allows additional filtering based on the properties of Item objects", # noqa: E501 + json_schema_extra={ + "example": '{"eo:cloud_cover": {"gte": 95}}', + }, + ), + ] = attr.ib(default=None) class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] = None + query: Optional[Dict[str, Dict[str, Any]]] = Field( + None, + description="Allows additional filtering based on the properties of Item objects", # noqa: E501 + json_schema_extra={ + "example": {"eo:cloud_cover": {"gte": 95}}, + }, + ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 8eeccba0c..e1c22eea3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -4,21 +4,46 @@ import attr from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from stac_pydantic.api.extensions.sort import SortExtension as PostSortModel from typing_extensions import Annotated from stac_fastapi.types.search import APIRequest, str2list +def _sort_converter( + val: Annotated[ + Optional[str], + Query( + description="An array of property names, prefixed by either '+' for ascending or '-' for descending. If no prefix is provided, '+' is assumed.", # noqa: E501 + json_schema_extra={ + "example": "-gsd,-datetime", + }, + ), + ], +) -> Optional[List[str]]: + return str2list(val) + + @attr.s class SortExtensionGetRequest(APIRequest): """Sortby Parameter for GET requests.""" - sortby: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) + sortby: Optional[List[str]] = attr.ib(default=None, converter=_sort_converter) class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] = None + sortby: Optional[List[PostSortModel]] = Field( + None, + description="An array of property (field) names, and direction in form of '{'field': '', 'direction':''}'", # noqa: E501 + json_schema_extra={ + "example": [ + { + "field": "properties.created", + "direction": "asc", + } + ], + }, + ) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index b8ae23c86..064ae10cb 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -6,7 +6,7 @@ import attr from fastapi import Query -from pydantic import PositiveInt +from pydantic import Field, PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search from stac_pydantic.shared import BBox @@ -23,11 +23,13 @@ def crop(v: PositiveInt) -> PositiveInt: return v -def str2list(x: str) -> Optional[List]: +def str2list(x: str) -> Optional[List[str]]: """Convert string to list base on , delimiter.""" if x: return x.split(",") + return None + def str2bbox(x: str) -> Optional[BBox]: """Convert string to BBox based on , delimiter.""" @@ -36,6 +38,68 @@ def str2bbox(x: str) -> Optional[BBox]: assert len(t) == 4 return t + return None + + +def _collection_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of collection Ids to search for items.", + json_schema_extra={ + "example": "collection1,collection2", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + +def _ids_converter( + val: Annotated[ + Optional[str], + Query( + description="Array of Item ids to return.", + json_schema_extra={ + "example": "item1,item2", + }, + ), + ] = None, +) -> Optional[List[str]]: + return str2list(val) + + +def _bbox_converter( + val: Annotated[ + Optional[str], + Query( + description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 + json_schema_extra={ + "example": "-175.05,-85.05,175.05,85.05", + }, + ), + ] = None, +) -> Optional[BBox]: + return str2bbox(val) + + +def _datetime_converter( + val: Annotated[ + Optional[str], + Query( + description="""Only return items that have a temporal property that intersects this value.\n +Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 + openapi_examples={ + "datetime": {"value": "2018-02-12T23:20:50Z"}, + "closed-interval": {"value": "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"}, + "open-interval-from": {"value": "2018-02-12T00:00:00Z/.."}, + "open-interval-to": {"value": "../2018-03-18T12:31:12Z"}, + }, + ), + ] = None, +): + return str_to_interval(val) + # Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 NumType = Union[float, int] @@ -56,19 +120,71 @@ def kwargs(self) -> Dict: class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" - collections: Annotated[Optional[str], Query()] = attr.ib( - default=None, converter=str2list + collections: Optional[List[str]] = attr.ib( + default=None, converter=_collection_converter ) - ids: Annotated[Optional[str], Query()] = attr.ib(default=None, converter=str2list) - bbox: Annotated[Optional[BBox], Query()] = attr.ib(default=None, converter=str2bbox) - intersects: Annotated[Optional[str], Query()] = attr.ib(default=None) - datetime: Annotated[Optional[DateTimeType], Query()] = attr.ib( - default=None, converter=str_to_interval + ids: Optional[List[str]] = attr.ib(default=None, converter=_ids_converter) + bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) + intersects: Annotated[ + Optional[str], + Query( + description="""Only return items intersecting this GeoJSON Geometry. Mutually exclusive with **bbox**. \n +*Remember to URL encode the GeoJSON geometry when using GET request*.""", # noqa: E501 + openapi_examples={ + "madrid": { + "value": { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-3.8549260500072933, 40.54923557897152], + [-3.8549260500072933, 40.29428000041938], + [-3.516597069715033, 40.29428000041938], + [-3.516597069715033, 40.54923557897152], + [-3.8549260500072933, 40.54923557897152], + ] + ], + "type": "Polygon", + }, + }, + }, + "new-york": { + "value": { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [-74.50117532354284, 41.128266394414055], + [-74.50117532354284, 40.35633909727355], + [-73.46713183168603, 40.35633909727355], + [-73.46713183168603, 41.128266394414055], + [-74.50117532354284, 41.128266394414055], + ] + ], + "type": "Polygon", + }, + }, + }, + }, + ), + ] = attr.ib(default=None) + datetime: Optional[DateTimeType] = attr.ib( + default=None, converter=_datetime_converter ) - limit: Annotated[Optional[int], Query()] = attr.ib(default=10) + limit: Annotated[ + Optional[int], + Query( + description="Limits the number of results that are included in each page of the response." # noqa: E501 + ), + ] = attr.ib(default=10) class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" - limit: Optional[Limit] = 10 + limit: Optional[Limit] = Field( + 10, + description="Limits the number of results that are included in each page of the response.", # noqa: E501 + )