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]