diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 514e31496..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 @@ -76,3 +52,45 @@ jobs: - uses: actions/checkout@v4 - name: Test generating docs run: make docs + + benchmark: + needs: [test] + runs-on: ubuntu-20.04 + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install types + run: | + python -m pip install ./stac_fastapi/types[dev] + + - 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 + + - name: Store and benchmark result + uses: benchmark-action/github-action-benchmark@v1 + with: + name: STAC FastAPI Benchmarks + tool: 'pytest' + output-file-path: output.json + alert-threshold: '130%' + comment-on-alert: true + fail-on-alert: false + # GitHub API token to make a commit comment + github-token: ${{ secrets.GITHUB_TOKEN }} + gh-pages-branch: 'gh-benchmarks' + # Make a commit only if main + auto-push: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index b29f0732a..4715015e9 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -8,7 +8,6 @@ on: # Rebuild website when docs have changed or code has changed - "README.md" - "docs/**" - - "mkdocs.yml" - "**.py" workflow_dispatch: @@ -28,27 +27,19 @@ jobs: - name: Install dependencies run: | - pip install --upgrade pip - pip install \ - stac_fastapi/api[docs] \ + python -m pip install --upgrade pip + python -m pip install \ stac_fastapi/types[docs] \ + stac_fastapi/api[docs] \ stac_fastapi/extensions[docs] \ - name: update API docs run: | pdocs as_markdown \ - --output_dir docs/api/ \ + --output_dir docs/src/api/ \ --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 + run: mkdocs gh-deploy --force -f docs/mkdocs.yml diff --git a/CHANGES.md b/CHANGES.md index 837717b8b..8856da996 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,8 +4,22 @@ ### 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)) - `id`, `title`, `description` and `api_version` fields can be customized via env variables +### Changed + +* Updated the collection update endpoint to match with the collection-transaction extension. ([#630](https://github.com/stac-utils/stac-fastapi/issues/630)) +* Improve bbox and datetime typing ([#490](https://github.com/stac-utils/stac-fastapi/pull/490) +* Add `items` link to inferred link relations ([#634](https://github.com/stac-utils/stac-fastapi/issues/634)) +* Make sure FastAPI uses Pydantic validation and serialization by not wrapping endpoint output with a Response object ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) +* Allow `GeometryCollections` for `intersects` parameter in POST search queries ([#548](https://github.com/stac-utils/stac-fastapi/pull/548)) + +### Removed + +* Deprecate `response_class` option in `stac_fastapi.api.routes.create_async_endpoint` method ([#650](https://github.com/stac-utils/stac-fastapi/pull/650)) + ## [2.4.9] - 2023-11-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cadb694e9..c89495c3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,31 +5,31 @@ Issues and pull requests are more than welcome. **dev install** ```bash -$ git clone https://github.com/stac-utils/stac-fastapi.git -$ cd stac-fastapi -$ pip install -e stac_fastapi/api[dev] +git clone https://github.com/stac-utils/stac-fastapi.git +cd stac-fastapi +python -m pip install -e stac_fastapi/api[dev] ``` -**Python3.8 only** +**pre-commit** -This repo is set to use `pre-commit` to run *ruff*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. +This repo is set to use `pre-commit` to run *ruff*, *pydocstring* and mypy when committing new code. ```bash -$ pre-commit install +pre-commit install ``` ### Docs ```bash -$ git clone https://github.com/stac-utils/stac-fastapi.git -$ cd stac-fastapi -$ pip install -e stac_fastapi/api["docs"] +git clone https://github.com/stac-utils/stac-fastapi.git +cd stac-fastapi +python pip install -e stac_fastapi/api["docs"] ``` Hot-reloading docs: ```bash -$ mkdocs serve +$ mkdocs serve -f docs/mkdocs.yml ``` To manually deploy docs (note you should never need to do this because GitHub @@ -38,11 +38,11 @@ Actions deploys automatically for new commits.): ```bash Create API documentations $ pdocs as_markdown \ - --output_dir docs/api/ \ + --output_dir docs/src/api/ \ --exclude_source \ --overwrite \ stac_fastapi # deploy -$ mkdocs gh-deploy +$ mkdocs gh-deploy -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 517b9b996..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: @@ -18,3 +18,7 @@ docs-image: docs: docs-image docker-compose -f docker-compose.docs.yml \ run docs + +.PHONY: test +test: image + 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/docs/contributing.md b/docs/contributing.md deleted file mode 120000 index 44fcc6343..000000000 --- a/docs/contributing.md +++ /dev/null @@ -1 +0,0 @@ -../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md deleted file mode 120000 index 32d46ee88..000000000 --- a/docs/index.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/mkdocs.yml b/docs/mkdocs.yml similarity index 97% rename from mkdocs.yml rename to docs/mkdocs.yml index 60374dc3b..dff2035ca 100644 --- a/mkdocs.yml +++ b/docs/mkdocs.yml @@ -4,7 +4,10 @@ site_description: STAC FastAPI. # Repository repo_name: "stac-utils/stac-fastapi" repo_url: "https://github.com/stac-utils/stac-fastapi" -edit_uri: "blob/master/docs/src/" +edit_uri: "blob/main/docs/src/" + +docs_dir: 'src' +site_dir: 'build' # Social links extra: @@ -15,6 +18,7 @@ extra: # Layout nav: - Home: "index.md" + - Tips and Tricks: tips-and-tricks.md - API: - packages: api/stac_fastapi/index.md - stac_fastapi.api: @@ -70,9 +74,9 @@ nav: - search: api/stac_fastapi/types/search.md - stac: api/stac_fastapi/types/stac.md - version: api/stac_fastapi/types/version.md + - Performance Benchmarks: benchmarks.html - Development - Contributing: "contributing.md" - Release Notes: "release-notes.md" - - Tips and Tricks: tips-and-tricks.md plugins: - search diff --git a/docs/release-notes.md b/docs/release-notes.md deleted file mode 120000 index cf547089d..000000000 --- a/docs/release-notes.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGES.md \ No newline at end of file diff --git a/docs/src/benchmarks.html b/docs/src/benchmarks.html new file mode 100644 index 000000000..f813e09b0 --- /dev/null +++ b/docs/src/benchmarks.html @@ -0,0 +1,292 @@ + + + + + + + Benchmarks + + + + +
+ + + + + + + diff --git a/docs/src/contributing.md b/docs/src/contributing.md new file mode 120000 index 000000000..f939e75f2 --- /dev/null +++ b/docs/src/contributing.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md new file mode 120000 index 000000000..fe8400541 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/docs/src/release-notes.md b/docs/src/release-notes.md new file mode 120000 index 000000000..8980b4a7a --- /dev/null +++ b/docs/src/release-notes.md @@ -0,0 +1 @@ +../../CHANGES.md \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/src/stylesheets/extra.css similarity index 100% rename from docs/stylesheets/extra.css rename to docs/src/stylesheets/extra.css diff --git a/docs/tips-and-tricks.md b/docs/src/tips-and-tricks.md similarity index 100% rename from docs/tips-and-tricks.md rename to docs/src/tips-and-tricks.md diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 1e3b8002f..a5bfd897e 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -23,6 +23,9 @@ "requests", "pystac[validation]==1.*", ], + "benchmark": [ + "pytest-benchmark", + ], "docs": ["mkdocs", "mkdocs-material", "pdocs"], } @@ -38,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/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 58358de59..029a131f7 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -135,9 +135,7 @@ def register_landing_page(self): response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint( - self.client.landing_page, EmptyRequest, self.response_class - ), + endpoint=create_async_endpoint(self.client.landing_page, EmptyRequest), ) def register_conformance_classes(self): @@ -156,9 +154,7 @@ def register_conformance_classes(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint( - self.client.conformance, EmptyRequest, self.response_class - ), + endpoint=create_async_endpoint(self.client.conformance, EmptyRequest), ) def register_get_item(self): @@ -175,9 +171,7 @@ 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, GeoJSONResponse - ), + endpoint=create_async_endpoint(self.client.get_item, ItemUri), ) def register_post_search(self): @@ -198,7 +192,7 @@ def register_post_search(self): response_model_exclude_none=True, methods=["POST"], endpoint=create_async_endpoint( - self.client.post_search, self.search_post_request_model, GeoJSONResponse + self.client.post_search, self.search_post_request_model ), ) @@ -220,7 +214,7 @@ def register_get_search(self): response_model_exclude_none=True, methods=["GET"], endpoint=create_async_endpoint( - self.client.get_search, self.search_get_request_model, GeoJSONResponse + self.client.get_search, self.search_get_request_model ), ) @@ -240,9 +234,7 @@ 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, self.response_class - ), + endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest), ) def register_get_collection(self): @@ -259,9 +251,7 @@ 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, self.response_class - ), + endpoint=create_async_endpoint(self.client.get_collection, CollectionUri), ) def register_get_item_collection(self): @@ -290,9 +280,7 @@ 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, GeoJSONResponse - ), + endpoint=create_async_endpoint(self.client.item_collection, request_model), ) def register_core(self): diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3d33b4e18..53f376aa0 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -7,13 +7,15 @@ from fastapi import Body, 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 +from stac_fastapi.types.rfc3339 import DateTimeType from stac_fastapi.types.search import ( APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, - str2list, + str2bbox, ) @@ -124,8 +126,8 @@ class ItemCollectionUri(CollectionUri): """Get item collection.""" limit: int = attr.ib(default=10) - bbox: Optional[str] = attr.ib(default=None, converter=str2list) - datetime: Optional[str] = attr.ib(default=None) + bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) + datetime: Optional[DateTimeType] = attr.ib(default=None) class POSTTokenPagination(BaseModel): diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index f4eb759af..66b76d2d7 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -1,6 +1,8 @@ """Route factories.""" + import functools import inspect +import warnings from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union from fastapi import Depends, params @@ -8,18 +10,16 @@ from pydantic import BaseModel from starlette.concurrency import run_in_threadpool from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import Response from starlette.routing import BaseRoute, Match from starlette.status import HTTP_204_NO_CONTENT from stac_fastapi.api.models import APIRequest -def _wrap_response(resp: Any, response_class: Type[Response]) -> Response: - if isinstance(resp, Response): +def _wrap_response(resp: Any) -> Any: + if resp is not None: return resp - elif resp is not None: - return response_class(resp) else: # None is returned as 204 No Content return Response(status_code=HTTP_204_NO_CONTENT) @@ -37,12 +37,19 @@ async def run(*args, **kwargs): def create_async_endpoint( func: Callable, request_model: Union[Type[APIRequest], Type[BaseModel], Dict], - response_class: Type[Response] = JSONResponse, + response_class: Optional[Type[Response]] = None, ): """Wrap a function in a coroutine which may be used to create a FastAPI endpoint. Synchronous functions are executed asynchronously using a background thread. """ + + if response_class: + warnings.warns( + "`response_class` option is deprecated, please set the Response class directly in the endpoint.", # noqa: E501 + DeprecationWarning, + ) + if not inspect.iscoroutinefunction(func): func = sync_to_async(func) @@ -53,9 +60,7 @@ async def _endpoint( request_data: request_model = Depends(), # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request=request, **request_data.kwargs()), response_class - ) + return _wrap_response(await func(request=request, **request_data.kwargs())) elif issubclass(request_model, BaseModel): @@ -64,9 +69,7 @@ async def _endpoint( request_data: request_model, # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request_data, request=request), response_class - ) + return _wrap_response(await func(request_data, request=request)) else: @@ -75,9 +78,7 @@ async def _endpoint( request_data: Dict[str, Any], # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request_data, request=request), response_class - ) + return _wrap_response(await func(request_data, request=request)) return _endpoint diff --git a/stac_fastapi/api/tests/benchmarks.py b/stac_fastapi/api/tests/benchmarks.py new file mode 100644 index 000000000..ad73d2424 --- /dev/null +++ b/stac_fastapi/api/tests/benchmarks.py @@ -0,0 +1,190 @@ +from datetime import datetime +from typing import List, Optional, Union + +import pytest +from stac_pydantic.api.utils import link_factory +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient, BaseSearchPostRequest, NumType + +collection_links = link_factory.CollectionLinks("/", "test").create_links() +item_links = link_factory.ItemLinks("/", "test", "test").create_links() + + +collections = [ + stac_types.Collection( + id=f"test_collection_{n}", + 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.dict(exclude_none=True), + ) + for n in range(0, 10) +] + +items = [ + stac_types.Item( + id=f"test_item_{n}", + type="Feature", + 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), + assets={}, + ) + for n in range(0, 1000) +] + + +class CoreClient(BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> stac_types.ItemCollection: + raise NotImplementedError + + 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_types.ItemCollection: + raise NotImplementedError + + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + raise NotImplementedError + + def all_collections(self, **kwargs) -> stac_types.Collections: + return stac_types.Collections( + collections=collections, + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + return collections[0] + + 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_types.ItemCollection: + return stac_types.ItemCollection( + type="FeatureCollection", + features=items[0:limit], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + +@pytest.fixture(autouse=True) +def client_validation() -> TestClient: + settings = ApiSettings(enable_response_models=True) + app = StacApi(settings=settings, client=CoreClient()) + with TestClient(app.app) as client: + yield client + + +@pytest.fixture(autouse=True) +def client_no_validation() -> TestClient: + settings = ApiSettings(enable_response_models=False) + app = StacApi(settings=settings, client=CoreClient()) + with TestClient(app.app) as client: + yield client + + +@pytest.mark.parametrize("limit", [1, 10, 50, 100, 200, 250, 1000]) +@pytest.mark.parametrize("validate", [True, False]) +def test_benchmark_items( + benchmark, client_validation, client_no_validation, validate, limit +): + """Benchmark items endpoint.""" + params = {"limit": limit} + + def f(p): + if validate: + return client_validation.get("/collections/fake_collection/items", params=p) + else: + return client_no_validation.get( + "/collections/fake_collection/items", params=p + ) + + benchmark.group = "Items With Model validation" if validate else "Items" + benchmark.name = ( + f"Items With Model validation ({limit})" + if validate + else f"Items Limit: ({limit})" + ) + benchmark.fullname = ( + f"Items With Model validation ({limit})" + if validate + else f"Items Limit: ({limit})" + ) + + response = benchmark(f, params) + assert response.status_code == 200 + + +@pytest.mark.parametrize("validate", [True, False]) +def test_benchmark_collection( + benchmark, client_validation, client_no_validation, validate +): + """Benchmark items endpoint.""" + + def f(): + if validate: + return client_validation.get("/collections/fake_collection") + else: + return client_no_validation.get("/collections/fake_collection") + + 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" + ) + + response = benchmark(f) + assert response.status_code == 200 + + +@pytest.mark.parametrize("validate", [True, False]) +def test_benchmark_collections( + benchmark, client_validation, client_no_validation, validate +): + """Benchmark items endpoint.""" + + def f(): + if validate: + return client_validation.get("/collections") + else: + return client_no_validation.get("/collections") + + benchmark.group = "Collections With Model validation" if validate else "Collections" + benchmark.name = "Collections With Model validation" if validate else "Collections" + benchmark.fullname = ( + "Collections With Model validation" if validate else "Collections" + ) + + response = benchmark(f) + assert response.status_code == 200 diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index ce49cef89..91b50371e 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -61,7 +61,7 @@ def test_openapi_content_type(self): def test_build_api_with_route_dependencies(self): routes = [ {"path": "/collections", "method": "POST"}, - {"path": "/collections", "method": "PUT"}, + {"path": "/collections/{collectionId}", "method": "PUT"}, {"path": "/collections/{collectionId}", "method": "DELETE"}, {"path": "/collections/{collectionId}/items", "method": "POST"}, {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, @@ -74,7 +74,7 @@ def test_build_api_with_route_dependencies(self): def test_add_route_dependencies_after_building_api(self): routes = [ {"path": "/collections", "method": "POST"}, - {"path": "/collections", "method": "PUT"}, + {"path": "/collections/{collectionId}", "method": "PUT"}, {"path": "/collections/{collectionId}", "method": "DELETE"}, {"path": "/collections/{collectionId}/items", "method": "POST"}, {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, 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/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 480ff09b5..0ebcc6194 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,4 +1,5 @@ """Transaction extension.""" + from typing import List, Optional, Type, Union import attr @@ -117,10 +118,10 @@ def register_create_collection(self): ) def register_update_collection(self): - """Register update collection endpoint (PUT /collections).""" + """Register update collection endpoint (PUT /collections/{collection_id}).""" self.router.add_api_route( name="Update Collection", - path="/collections", + path="/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, 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", diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 31c59eedc..2790c2b76 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,14 +1,14 @@ """Base clients.""" + import abc import os -from datetime import datetime from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin import attr from fastapi import Request from stac_pydantic.links import Relations -from stac_pydantic.shared import MimeTypes +from stac_pydantic.shared import BBox, MimeTypes from stac_pydantic.version import STAC_VERSION from starlette.responses import Response @@ -16,6 +16,7 @@ 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 @@ -102,18 +103,18 @@ def create_collection( @abc.abstractmethod def update_collection( - self, collection: stac_types.Collection, **kwargs + self, collection_id: str, collection: stac_types.Collection, **kwargs ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. - Called with `PUT /collections`. It is expected that this item already - exists. The update should do a diff against the saved collection and + 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. Args: - collection: the collection (must be complete) - collection_id: the id of the collection from the resource path + collection_id: id of the existing collection to be updated + collection: the updated collection (must be complete) Returns: The updated collection. @@ -215,17 +216,18 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection: stac_types.Collection, **kwargs + self, collection_id: str, collection: stac_types.Collection, **kwargs ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. - Called with `PUT /collections`. It is expected that this item already - exists. The update should do a diff against the saved collection and + 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. Args: - collection: the collection (must be complete) + collection_id: id of the existing collection to be updated + collection: the updated collection (must be complete) Returns: The updated collection. @@ -370,6 +372,20 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: extension_schemas=[], ) + # Add Queryables link + 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", + "title": "Queryables", + "href": urljoin(base_url, "queryables"), + "method": "GET", + } + ) + # Add Collections links collections = self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: @@ -439,8 +455,8 @@ def get_search( self, collections: Optional[List[str]] = None, ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, query: Optional[str] = None, token: Optional[str] = None, @@ -502,8 +518,8 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: def item_collection( self, collection_id: str, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, limit: int = 10, token: str = None, **kwargs, @@ -567,6 +583,22 @@ 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( + { + # 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", + } + ) + + # Add Collections links collections = await self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: landing_page["links"].append( @@ -584,9 +616,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( - str(request.base_url), request.app.openapi_url.lstrip("/") - ), + "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), } ) @@ -596,9 +626,7 @@ async 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": urljoin(base_url, request.app.docs_url.lstrip("/")), } ) @@ -635,8 +663,8 @@ async def get_search( self, collections: Optional[List[str]] = None, ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, query: Optional[str] = None, token: Optional[str] = None, @@ -702,8 +730,8 @@ async def get_collection( async def item_collection( self, collection_id: str, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, + bbox: Optional[BBox] = None, + datetime: Optional[DateTimeType] = None, limit: int = 10, token: str = None, **kwargs, diff --git a/stac_fastapi/types/stac_fastapi/types/links.py b/stac_fastapi/types/stac_fastapi/types/links.py index 2a4c954d0..28f05d6c0 100644 --- a/stac_fastapi/types/stac_fastapi/types/links.py +++ b/stac_fastapi/types/stac_fastapi/types/links.py @@ -10,7 +10,7 @@ # These can be inferred from the item/collection so they aren't included in the database # Instead they are dynamically generated when querying the database using the # classes defined below -INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"] +INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root", "items"] def filter_links(links: List[Dict]) -> List[Dict]: diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 3c4cee30d..43baa8d53 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -1,7 +1,7 @@ """rfc3339.""" import re from datetime import datetime, timezone -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import iso8601 from pystac.utils import datetime_to_str @@ -11,6 +11,13 @@ r"(Z|([-+])(\d\d):(\d\d))$" ) +DateTimeType = Union[ + datetime, + Tuple[datetime, datetime], + Tuple[datetime, None], + Tuple[None, datetime], +] + def rfc3339_str_to_datetime(s: str) -> datetime: """Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`. @@ -40,7 +47,7 @@ def rfc3339_str_to_datetime(s: str) -> datetime: def str_to_interval( interval: str, -) -> Optional[Tuple[Optional[datetime], Optional[datetime]]]: +) -> Optional[DateTimeType]: """Extract a tuple of datetimes from an interval string. Interval strings are defined by @@ -59,7 +66,10 @@ def str_to_interval( raise ValueError("Empty interval string is invalid.") values = interval.split("/") - if len(values) != 2: + 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." ) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 8d086a9be..0851c1d30 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -12,6 +12,7 @@ import attr from geojson_pydantic.geometries import ( + GeometryCollection, LineString, MultiLineString, MultiPoint, @@ -20,13 +21,13 @@ Polygon, _GeometryBase, ) -from pydantic import BaseModel, ConstrainedInt, validator +from pydantic import BaseModel, ConstrainedInt, Field, validator from pydantic.errors import NumberNotGtError from pydantic.validators import int_validator from stac_pydantic.shared import BBox from stac_pydantic.utils import AutoValueEnum -from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime, str_to_interval +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] @@ -82,6 +83,14 @@ def str2list(x: str) -> Optional[List]: return x.split(",") +def str2bbox(x: str) -> Optional[BBox]: + """Convert string to BBox based on , delimiter.""" + if x: + t = tuple(float(v) for v in str2list(x)) + assert len(t) == 4 + return t + + @attr.s # type:ignore class APIRequest(abc.ABC): """Generic API Request base class.""" @@ -98,9 +107,9 @@ class BaseSearchGetRequest(APIRequest): collections: Optional[str] = attr.ib(default=None, converter=str2list) ids: Optional[str] = attr.ib(default=None, converter=str2list) - bbox: Optional[str] = attr.ib(default=None, converter=str2list) - intersects: Optional[str] = attr.ib(default=None) - datetime: Optional[str] = attr.ib(default=None) + bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) + intersects: Optional[str] = attr.ib(default=None, converter=str2list) + datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) limit: Optional[int] = attr.ib(default=10) @@ -119,22 +128,28 @@ class BaseSearchPostRequest(BaseModel): ids: Optional[List[str]] bbox: Optional[BBox] intersects: Optional[ - Union[Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon] + Union[ + Point, + MultiPoint, + LineString, + MultiLineString, + Polygon, + MultiPolygon, + GeometryCollection, + ] ] - datetime: Optional[str] - limit: Optional[Limit] = 10 + datetime: Optional[DateTimeType] + limit: Optional[Limit] = Field(default=10) @property def start_date(self) -> Optional[datetime]: """Extract the start date from the datetime string.""" - interval = str_to_interval(self.datetime) - return interval[0] if interval else None + return self.datetime[0] if self.datetime else None @property def end_date(self) -> Optional[datetime]: """Extract the end date from the datetime string.""" - interval = str_to_interval(self.datetime) - return interval[1] if interval else None + return self.datetime[1] if self.datetime else None @validator("intersects") def validate_spatial(cls, v, values): @@ -143,10 +158,12 @@ def validate_spatial(cls, v, values): raise ValueError("intersects and bbox parameters are mutually exclusive") return v - @validator("bbox") - def validate_bbox(cls, v: BBox): + @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 @@ -173,34 +190,11 @@ def validate_bbox(cls, v: BBox): return v - @validator("datetime") - def validate_datetime(cls, v): - """Validate datetime.""" - if "/" in v: - values = v.split("/") - else: - # Single date is interpreted as end date - values = ["..", v] - - dates = [] - for value in values: - if value == ".." or value == "": - dates.append("..") - continue - - # throws ValueError if invalid RFC 3339 string - dates.append(rfc3339_str_to_datetime(value)) - - if dates[0] == ".." and dates[1] == "..": - raise ValueError( - "Invalid datetime range, both ends of range may not be open" - ) - - if ".." not in dates and dates[0] > dates[1]: - raise ValueError( - "Invalid datetime range, must match format (begin_date, end_date)" - ) - + @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 diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index f0876cef0..51bb6e652 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -2,6 +2,8 @@ import sys from typing import Any, Dict, List, Literal, Optional, Union +from stac_pydantic.shared import BBox + # 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 @@ -64,7 +66,7 @@ class Item(TypedDict, total=False): stac_extensions: Optional[List[str]] id: str geometry: Dict[str, Any] - bbox: List[NumType] + bbox: BBox properties: Dict[str, Any] links: List[Dict[str, Any]] assets: Dict[str, Any]