From 013caee4af280efebf274d4efa65b9d60424ac1f Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 20:39:38 -0500 Subject: [PATCH 01/37] update to pydantic 2 --- .gitignore | 1 + Makefile | 4 +- stac_fastapi/api/setup.py | 4 +- stac_fastapi/api/stac_fastapi/api/app.py | 4 +- stac_fastapi/api/stac_fastapi/api/models.py | 4 +- stac_fastapi/api/tests/test_api.py | 85 ++++++-- stac_fastapi/extensions/setup.py | 4 +- .../extensions/core/transaction.py | 17 +- .../extensions/tests/test_transaction.py | 7 +- stac_fastapi/types/setup.py | 7 +- .../types/stac_fastapi/types/config.py | 2 +- stac_fastapi/types/stac_fastapi/types/core.py | 91 ++++---- .../types/stac_fastapi/types/search.py | 206 ++---------------- stac_fastapi/types/stac_fastapi/types/stac.py | 90 -------- 14 files changed, 155 insertions(+), 371 deletions(-) delete mode 100644 stac_fastapi/types/stac_fastapi/types/stac.py 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/Makefile b/Makefile index 517b9b996..adb7e42c1 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,9 @@ 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] + pip install -e ./stac_fastapi/extensions[dev] && \ + pip install -e ./stac_fastapi/api[dev] .PHONY: docs-image docs-image: diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 1e3b8002f..ced91984e 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -7,8 +7,8 @@ install_requires = [ "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", + "pydantic[dotenv]>=2", + "stac_pydantic>=3", "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 28fff912c..bacd16207 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -9,7 +9,7 @@ from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage from stac_pydantic.api.collections import Collections -from stac_pydantic.version import STAC_VERSION +from stac_pydantic.api.version import STAC_API_VERSION from starlette.responses import JSONResponse, Response from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers @@ -85,7 +85,7 @@ class StacApi: router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) title: str = attr.ib(default="stac-fastapi") api_version: str = attr.ib(default="0.1") - stac_version: str = attr.ib(default=STAC_VERSION) + stac_version: str = attr.ib(default=STAC_API_VERSION) description: str = attr.ib(default="stac-fastapi") search_get_request_model: Type[BaseSearchGetRequest] = attr.ib( default=BaseSearchGetRequest diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3d33b4e18..3a25fb99c 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -6,7 +6,7 @@ import attr from fastapi import Body, Path from pydantic import BaseModel, create_model -from pydantic.fields import UndefinedType +from pydantic.fields import PydanticUndefined from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.search import ( @@ -48,7 +48,7 @@ def create_request_model( field_info = v.field_info body = Body( None - if isinstance(field_info.default, UndefinedType) + if isinstance(field_info.default, PydanticUndefined) else field_info.default, default_factory=field_info.default_factory, alias=field_info.alias, diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index ce49cef89..5e751fc7e 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,4 +1,6 @@ +import pytest from fastapi import Depends, HTTPException, security, status +from stac_pydantic import Collection, Item from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -41,7 +43,7 @@ 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 ( @@ -58,27 +60,51 @@ 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", "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", "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", "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", "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)]) @@ -138,3 +164,32 @@ def must_be_bob( detail="You're not Bob", headers={"WWW-Authenticate": "Basic"}, ) + + +@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=[], + ).model_dump_json() + + +@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=[], + assets={}, + ).model_dump_json() diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index a70ea5855..ed0f90807 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -7,8 +7,8 @@ install_requires = [ "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", + "pydantic[dotenv]>=2", + "stac_pydantic>=3", "stac-fastapi.types", "stac-fastapi.api", ] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 480ff09b5..8998bdda6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -3,12 +3,11 @@ import attr from fastapi import APIRouter, Body, FastAPI -from stac_pydantic import Collection, Item +from stac_pydantic import Collection, Item, ItemCollection 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.config import ApiSettings from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension @@ -18,16 +17,14 @@ 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 @@ -111,9 +108,7 @@ def register_create_collection(self): 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): @@ -126,9 +121,7 @@ def register_update_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["PUT"], - endpoint=create_async_endpoint( - self.client.update_collection, stac_types.Collection - ), + endpoint=create_async_endpoint(self.client.update_collection, Collection), ) def register_delete_collection(self): diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index fc5acc2cf..29fed4180 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -2,13 +2,14 @@ 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 Item, ItemCollection class DummyCoreClient(BaseCoreClient): @@ -35,7 +36,7 @@ class DummyTransactionsClient(BaseTransactionsClient): """Defines a pattern for implementing the STAC transaction extension.""" def create_item(self, item: Union[Item, ItemCollection], *args, **kwargs): - return {"created": True, "type": item["type"]} + return {"created": True, "type": item.type} def update_item(self, *args, **kwargs): raise NotImplementedError @@ -114,7 +115,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", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 9a06fda95..e6024d3d3 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -6,10 +6,11 @@ desc = f.read() install_requires = [ - "fastapi>=0.73.0", + "fastapi>=0.100.0", "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", + "pydantic[dotenv]>=2", + "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 b3f22fb65..dad2fb224 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,7 +1,7 @@ """stac_fastapi.types.config module.""" from typing import Optional, Set -from pydantic import BaseSettings +from pydantic_settings import BaseSettings class ApiSettings(BaseSettings): diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 5798de968..70397c4c2 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -6,17 +6,16 @@ import attr from fastapi import Request +from stac_pydantic import Collection, Item, ItemCollection, api +from stac_pydantic.api.version import STAC_API_VERSION from stac_pydantic.links import Relations from stac_pydantic.shared import 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.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.search import BaseSearchPostRequest -from stac_fastapi.types.stac import Conformance NumType = Union[float, int] StacType = Dict[str, Any] @@ -30,9 +29,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`. @@ -48,8 +47,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 @@ -69,7 +68,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}` @@ -85,8 +84,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`. @@ -101,8 +100,8 @@ def create_collection( @abc.abstractmethod def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections`. It is expected that this item already @@ -122,7 +121,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}` @@ -144,9 +143,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`. @@ -162,8 +161,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 @@ -182,7 +181,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}` @@ -198,8 +197,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`. @@ -214,8 +213,8 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections`. It is expected that this item already @@ -234,7 +233,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}` @@ -252,7 +251,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="stac-fastapi") title: str = attr.ib(default="stac-fastapi") description: str = attr.ib(default="stac-fastapi") @@ -262,8 +261,8 @@ def _landing_page( base_url: str, conformance_classes: List[str], extension_schemas: List[str], - ) -> stac_types.LandingPage: - landing_page = stac_types.LandingPage( + ) -> api.LandingPage: + landing_page = api.LandingPage( type="Catalog", id=self.landing_page_id, title=self.title, @@ -351,7 +350,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> stac_types.LandingPage: + def landing_page(self, **kwargs) -> api.LandingPage: """Landing page. Called with `GET /`. @@ -405,7 +404,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: return landing_page - def conformance(self, **kwargs) -> stac_types.Conformance: + def conformance(self, **kwargs) -> api.ConformanceClasses: """Conformance classes. Called with `GET /conformance`. @@ -413,12 +412,12 @@ def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return api.ConformanceClasses(conformsTo=self.conformance_classes()) @abc.abstractmethod def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -445,7 +444,7 @@ def get_search( sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -456,7 +455,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) -> api.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -471,7 +470,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) -> api.Collections: """Get all available collections. Called with `GET /collections`. @@ -482,7 +481,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) -> api.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -504,7 +503,7 @@ def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -549,7 +548,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) -> api.LandingPage: """Landing page. Called with `GET /`. @@ -601,7 +600,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: return landing_page - async def conformance(self, **kwargs) -> stac_types.Conformance: + async def conformance(self, **kwargs) -> api.ConformanceClasses: """Conformance classes. Called with `GET /conformance`. @@ -609,12 +608,12 @@ async def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return api.ConformanceClasses(conformsTo=self.conformance_classes()) @abc.abstractmethod async def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -641,7 +640,7 @@ async def get_search( sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -652,9 +651,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) -> api.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -669,7 +666,7 @@ async def get_item( ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> stac_types.Collections: + async def all_collections(self, **kwargs) -> api.Collections: """Get all available collections. Called with `GET /collections`. @@ -680,9 +677,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) -> api.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -704,7 +699,7 @@ async def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 8d086a9be..952ce8ee3 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -1,79 +1,22 @@ """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 Annotated, Dict, List, Optional import attr -from geojson_pydantic.geometries import ( - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Point, - Polygon, - _GeometryBase, -) -from pydantic import BaseModel, ConstrainedInt, 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 - -# 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_) +from pydantic import PositiveInt +from pydantic.functional_validators import AfterValidator +from stac_pydantic.api import Search + + +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]: @@ -82,6 +25,9 @@ def str2list(x: str) -> Optional[List]: return x.split(",") +Limit = Annotated[PositiveInt, AfterValidator(crop)] + + @attr.s # type:ignore class APIRequest(abc.ABC): """Generic API Request base class.""" @@ -104,125 +50,7 @@ class BaseSearchGetRequest(APIRequest): limit: Optional[int] = attr.ib(default=10) -class BaseSearchPostRequest(BaseModel): - """Search model. +class BaseSearchPostRequest(Search): + """Base arguments for POST Request.""" - 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] - ] - datetime: Optional[str] limit: Optional[Limit] = 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 - - @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 - - @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") - def validate_bbox(cls, v: BBox): - """Check order of supplied bbox coordinates.""" - if 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") - 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)" - ) - - 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 diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py deleted file mode 100644 index f0876cef0..000000000 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ /dev/null @@ -1,90 +0,0 @@ -"""STAC types.""" -import sys -from typing import Any, Dict, List, Literal, Optional, Union - -# 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 -# differentiate required and optional fields when subclassed. -if sys.version_info < (3, 9, 2): - from typing_extensions import TypedDict -else: - from typing import TypedDict - -NumType = Union[float, int] - - -class LandingPage(TypedDict, total=False): - """STAC Landing Page.""" - - type: str - stac_version: str - stac_extensions: Optional[List[str]] - id: str - title: str - description: str - conformsTo: List[str] - links: List[Dict[str, Any]] - - -class Conformance(TypedDict): - """STAC Conformance Classes.""" - - conformsTo: List[str] - - -class Catalog(TypedDict, total=False): - """STAC Catalog.""" - - type: str - stac_version: str - stac_extensions: Optional[List[str]] - id: str - title: Optional[str] - description: str - links: List[Dict[str, Any]] - - -class Collection(Catalog, total=False): - """STAC Collection.""" - - keywords: List[str] - license: str - providers: List[Dict[str, Any]] - extent: Dict[str, Any] - summaries: Dict[str, Any] - assets: Dict[str, Any] - - -class Item(TypedDict, total=False): - """STAC Item.""" - - type: Literal["Feature"] - stac_version: str - stac_extensions: Optional[List[str]] - id: str - geometry: Dict[str, Any] - bbox: List[NumType] - properties: Dict[str, Any] - links: List[Dict[str, Any]] - assets: Dict[str, Any] - collection: str - - -class ItemCollection(TypedDict, total=False): - """STAC Item Collection.""" - - type: Literal["FeatureCollection"] - features: List[Item] - links: List[Dict[str, Any]] - context: Optional[Dict[str, int]] - - -class Collections(TypedDict, total=False): - """All collections endpoint. - - https://github.com/radiantearth/stac-api-spec/tree/master/collections - """ - - collections: List[Collection] - links: List[Dict[str, Any]] From 669587edd86f5331da898c6ab04f9ab278c2594c Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 20:45:48 -0500 Subject: [PATCH 02/37] update changelog --- CHANGES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 16bc9a809..31fa25cf3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,11 @@ ## [Unreleased] +## Changes + +* Update to pydantic v2 and stac_pydantic v3 +* Removed internal Stac, Search and Operator Types in favor to stac_pydantic Types + ## [2.4.9] - 2023-11-17 ### Added From a168498d23cff5e8b986a289e1f86b27699cc15b Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 20:47:50 -0500 Subject: [PATCH 03/37] typo --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 31fa25cf3..d1fad03f3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,7 @@ ## Changes * Update to pydantic v2 and stac_pydantic v3 -* Removed internal Stac, Search and Operator Types in favor to stac_pydantic Types +* Removed internal Stac, Search and Operator Types in favor of stac_pydantic Types ## [2.4.9] - 2023-11-17 From cd9f75e3e16be469cac601d2846bec7da354a8c4 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 20:53:02 -0500 Subject: [PATCH 04/37] add CI for Python 3.12 --- .github/workflows/cicd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 514e31496..7d5c0461f 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -10,7 +10,7 @@ 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: From 781c46e3a18439fdcf98c3a60bbdef3d129dabbb Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 20:59:55 -0500 Subject: [PATCH 05/37] drop support for python 3.8 --- .github/workflows/cicd.yaml | 2 +- .pre-commit-config.yaml | 2 +- CHANGES.md | 1 + pyproject.toml | 2 +- stac_fastapi/api/setup.py | 7 +++++-- stac_fastapi/extensions/setup.py | 7 +++++-- stac_fastapi/types/setup.py | 7 +++++-- 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 7d5c0461f..219d415fa 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] timeout-minutes: 20 services: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 193edc5c7..51588b36c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,6 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.1.1 hooks: - id: black diff --git a/CHANGES.md b/CHANGES.md index d1fad03f3..fc0a5f1d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ * Update to pydantic v2 and stac_pydantic v3 * Removed internal Stac, Search and Operator Types in favor of stac_pydantic Types +* Drop support for Python 3.8 ## [2.4.9] - 2023-11-17 diff --git a/pyproject.toml b/pyproject.toml index 162a81b1e..3ab78e7fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,4 @@ 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"] +target-version = ["py39", "py310", "py311", "py312"] diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index ced91984e..9afdf49ce 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -32,12 +32,15 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "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 ed0f90807..87258d7e5 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -30,12 +30,15 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "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 e6024d3d3..509de601e 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -32,12 +32,15 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "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 ce7f6e838cc5197a856564f97e98b658c99bba31 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 21:03:13 -0500 Subject: [PATCH 06/37] update python version for docs --- .github/workflows/deploy_mkdocs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index b29f0732a..049e51a63 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -21,10 +21,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.12 - name: Install dependencies run: | From 19cce991a977ee449dd826974f60b107fb3ab649 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 21:07:19 -0500 Subject: [PATCH 07/37] update python for docs docker container --- .github/workflows/deploy_mkdocs.yml | 4 ++-- Dockerfile.docs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 049e51a63..06bdb0148 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -21,10 +21,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.11 - name: Install dependencies run: | diff --git a/Dockerfile.docs b/Dockerfile.docs index e3c7447e5..f430824e7 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 From 36044d6742946d84a753583b8f3bf96cdaaf5038 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 9 Feb 2024 21:08:15 -0500 Subject: [PATCH 08/37] update python version in dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2187ac53e..5eec26e49 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 From 921050162b2f32c0e379806cd1e8c10f37306637 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Mon, 12 Feb 2024 11:53:19 -0500 Subject: [PATCH 09/37] handle post requests --- stac_fastapi/api/stac_fastapi/api/models.py | 30 +++------------------ 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3a25fb99c..488e7b82d 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -4,9 +4,8 @@ from typing import Optional, Type, Union import attr -from fastapi import Body, Path +from fastapi import Path from pydantic import BaseModel, create_model -from pydantic.fields import PydanticUndefined from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.search import ( @@ -44,31 +43,8 @@ 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, PydanticUndefined) - 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.") From 1fa87b7bd517008a9cdd37824ed0ec0a6315b284 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Mon, 12 Feb 2024 12:22:41 -0500 Subject: [PATCH 10/37] test wrapper --- stac_fastapi/api/stac_fastapi/api/routes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index f4eb759af..1c2449ceb 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -1,4 +1,5 @@ """Route factories.""" + import functools import inspect from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union @@ -6,6 +7,7 @@ from fastapi import Depends, params from fastapi.dependencies.utils import get_parameterless_sub_dependant from pydantic import BaseModel +from stac_pydantic.api import LandingPage from starlette.concurrency import run_in_threadpool from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -18,6 +20,8 @@ def _wrap_response(resp: Any, response_class: Type[Response]) -> Response: if isinstance(resp, Response): return resp + elif isinstance(resp, LandingPage): + return resp elif resp is not None: return response_class(resp) else: # None is returned as 204 No Content From 653f3a6ce1d538dbae59d845e493c7c8bb314442 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Mon, 12 Feb 2024 12:27:45 -0500 Subject: [PATCH 11/37] pass through StacBaseModel --- stac_fastapi/api/stac_fastapi/api/routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index 1c2449ceb..0b8c2c128 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -7,7 +7,7 @@ from fastapi import Depends, params from fastapi.dependencies.utils import get_parameterless_sub_dependant from pydantic import BaseModel -from stac_pydantic.api import LandingPage +from stac_pydantic.shared import StacBaseModel from starlette.concurrency import run_in_threadpool from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -20,7 +20,7 @@ def _wrap_response(resp: Any, response_class: Type[Response]) -> Response: if isinstance(resp, Response): return resp - elif isinstance(resp, LandingPage): + elif isinstance(resp, StacBaseModel): return resp elif resp is not None: return response_class(resp) From 6727568ecca852463804c06b4dfbe2b67cfc3af4 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Tue, 20 Feb 2024 14:28:51 -0500 Subject: [PATCH 12/37] keep py38 --- .github/workflows/cicd.yaml | 2 +- CHANGES.md | 1 - pyproject.toml | 2 +- stac_fastapi/api/setup.py | 1 + stac_fastapi/extensions/setup.py | 1 + stac_fastapi/types/setup.py | 1 + stac_fastapi/types/stac_fastapi/types/search.py | 3 ++- 7 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 219d415fa..7d5c0461f 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] timeout-minutes: 20 services: diff --git a/CHANGES.md b/CHANGES.md index fc0a5f1d3..d1fad03f3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,6 @@ * Update to pydantic v2 and stac_pydantic v3 * Removed internal Stac, Search and Operator Types in favor of stac_pydantic Types -* Drop support for Python 3.8 ## [2.4.9] - 2023-11-17 diff --git a/pyproject.toml b/pyproject.toml index 3ab78e7fd..08a267798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,4 @@ known-third-party = ["stac_pydantic", "fastapi"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] [tool.black] -target-version = ["py39", "py310", "py311", "py312"] +target-version = ["py38", "py39", "py310", "py311", "py312"] diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 9afdf49ce..d8ab1a007 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -37,6 +37,7 @@ "Intended Audience :: Developers", "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", diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index 87258d7e5..0d1b7b113 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -35,6 +35,7 @@ "Intended Audience :: Developers", "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", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 509de601e..9858acac3 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -37,6 +37,7 @@ "Intended Audience :: Developers", "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", diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 952ce8ee3..4b7ce2bb0 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -3,12 +3,13 @@ """ import abc -from typing import Annotated, Dict, List, Optional +from typing import Dict, List, Optional import attr from pydantic import PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search +from typing_extensions import Annotated def crop(v: PositiveInt) -> PositiveInt: From e01e95ab7e2f23f60a6f0e00d226e257fcfe94db Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Tue, 20 Feb 2024 14:33:53 -0500 Subject: [PATCH 13/37] change install order --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index adb7e42c1..c413ba9cb 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,8 @@ image: install: pip install wheel && \ pip install -e ./stac_fastapi/types[dev] && \ - pip install -e ./stac_fastapi/extensions[dev] && \ - pip install -e ./stac_fastapi/api[dev] + pip install -e ./stac_fastapi/api[dev] && \ + pip install -e ./stac_fastapi/extensions[dev] .PHONY: docs-image docs-image: From db5cfb600dba211a223eaa63abeb81efe5ad9565 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Tue, 20 Feb 2024 14:34:36 -0500 Subject: [PATCH 14/37] lint --- stac_fastapi/api/stac_fastapi/api/app.py | 41 +++++++++++-------- stac_fastapi/api/stac_fastapi/api/config.py | 1 + .../api/stac_fastapi/api/middleware.py | 1 + stac_fastapi/api/stac_fastapi/api/openapi.py | 7 ++-- stac_fastapi/api/stac_fastapi/api/version.py | 1 + stac_fastapi/api/tests/test_api.py | 18 +++----- .../stac_fastapi/extensions/core/__init__.py | 1 + .../stac_fastapi/extensions/core/context.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 + .../stac_fastapi/extensions/core/sort/sort.py | 1 + .../extensions/core/transaction.py | 1 + .../extensions/third_party/__init__.py | 1 + .../third_party/bulk_transactions.py | 7 ++-- .../stac_fastapi/extensions/version.py | 1 + .../types/stac_fastapi/types/config.py | 1 + .../types/stac_fastapi/types/conformance.py | 1 + stac_fastapi/types/stac_fastapi/types/core.py | 1 + .../types/stac_fastapi/types/extension.py | 1 + .../types/stac_fastapi/types/rfc3339.py | 1 + .../types/stac_fastapi/types/version.py | 1 + 23 files changed, 54 insertions(+), 38 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index bacd16207..1ecc7b5ba 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 @@ -125,9 +126,9 @@ 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=( + LandingPage if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=False, response_model_exclude_none=True, @@ -146,9 +147,9 @@ 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=( + ConformanceClasses if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -187,9 +188,11 @@ 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=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -209,9 +212,11 @@ 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=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -230,9 +235,9 @@ 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 + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -280,9 +285,9 @@ 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=( + ItemCollection if self.settings.enable_response_models else None + ), 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/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/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index a38a70bae..84d72dab1 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,9 @@ 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/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9" diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 5e751fc7e..a84ec87b5 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -112,23 +112,17 @@ def test_add_route_dependencies_after_building_api(self, collection, item): class DummyCoreClient(core.BaseCoreClient): - def all_collections(self, *args, **kwargs): - ... + def all_collections(self, *args, **kwargs): ... - def get_collection(self, *args, **kwargs): - ... + def get_collection(self, *args, **kwargs): ... - def get_item(self, *args, **kwargs): - ... + def get_item(self, *args, **kwargs): ... - def get_search(self, *args, **kwargs): - ... + def get_search(self, *args, **kwargs): ... - def post_search(self, *args, **kwargs): - ... + def post_search(self, *args, **kwargs): ... - def item_collection(self, *args, **kwargs): - ... + def item_collection(self, *args, **kwargs): ... class DummyTransactionsClient(core.BaseTransactionsClient): 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/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py index 90faae914..cef6d0d74 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py @@ -1,4 +1,5 @@ """Context extension.""" + from typing import List, Optional import attr 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/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 8998bdda6..c5abd9add 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 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..7033bbd89 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,9 @@ 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/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9" diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index dad2fb224..c0a0d3c28 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,4 +1,5 @@ """stac_fastapi.types.config module.""" + from typing import Optional, Set from pydantic_settings import BaseSettings 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 70397c4c2..42954e8a9 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,4 +1,5 @@ """Base clients.""" + import abc from datetime import datetime from typing import Any, Dict, List, Optional, Union 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/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 3c4cee30d..6e953ebd4 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 diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9" From 02f2702a64476ee63bca90a524ce9fc4d2cc91de Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Tue, 20 Feb 2024 14:38:31 -0500 Subject: [PATCH 15/37] revert back to >=3.8 in setup.py --- stac_fastapi/api/setup.py | 2 +- stac_fastapi/extensions/setup.py | 2 +- stac_fastapi/types/setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index d8ab1a007..cb9097eca 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -32,7 +32,7 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.9", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index 0d1b7b113..a22d8815f 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -30,7 +30,7 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.9", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 9858acac3..7717f3a69 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -32,7 +32,7 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.9", + python_requires=">=3.8", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", From 8118f10be27f04d5ce2143b5fa9dbbd1b4fd92c3 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 16:04:59 -0500 Subject: [PATCH 16/37] add switch to use either TypeDict or StacPydantic Response --- stac_fastapi/api/stac_fastapi/api/models.py | 17 +- stac_fastapi/api/tests/conftest.py | 55 +++++++ stac_fastapi/api/tests/test_api.py | 31 ---- stac_fastapi/api/tests/test_app.py | 153 ++++++++++++++++++ stac_fastapi/api/tests/test_models.py | 101 ++++++++++++ .../extensions/core/sort/request.py | 2 +- .../types/stac_fastapi/types/config.py | 2 + stac_fastapi/types/stac_fastapi/types/core.py | 83 +++++++--- .../stac_fastapi/types/response_model.py | 26 +++ stac_fastapi/types/stac_fastapi/types/stac.py | 83 ++++++++++ .../types/tests/test_response_model.py | 40 +++++ 11 files changed, 528 insertions(+), 65 deletions(-) create mode 100644 stac_fastapi/api/tests/conftest.py create mode 100644 stac_fastapi/api/tests/test_app.py create mode 100644 stac_fastapi/api/tests/test_models.py create mode 100644 stac_fastapi/types/stac_fastapi/types/response_model.py create mode 100644 stac_fastapi/types/stac_fastapi/types/stac.py create mode 100644 stac_fastapi/types/tests/test_response_model.py diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 488e7b82d..2ca2a0b0f 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,7 +1,7 @@ """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 Path @@ -19,8 +19,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.""" @@ -51,9 +51,11 @@ def create_request_model( 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, @@ -63,8 +65,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/tests/conftest.py b/stac_fastapi/api/tests/conftest.py new file mode 100644 index 000000000..603e89cea --- /dev/null +++ b/stac_fastapi/api/tests/conftest.py @@ -0,0 +1,55 @@ +import pytest +from stac_pydantic import Collection, Item +from stac_pydantic.api.utils import link_factory + +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.model_dump_json() + + +@pytest.fixture +def collection_dict(_collection: Collection): + return _collection.model_dump(mode="json") + + +@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.model_dump_json() + + +@pytest.fixture +def item_dict(_item: Item): + return _item.model_dump(mode="json") diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index a84ec87b5..e47ec9747 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,6 +1,4 @@ -import pytest from fastapi import Depends, HTTPException, security, status -from stac_pydantic import Collection, Item from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -158,32 +156,3 @@ def must_be_bob( detail="You're not Bob", headers={"WWW-Authenticate": "Basic"}, ) - - -@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=[], - ).model_dump_json() - - -@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=[], - assets={}, - ).model_dump_json() diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py new file mode 100644 index 000000000..a560a8600 --- /dev/null +++ b/stac_fastapi/api/tests/test_app.py @@ -0,0 +1,153 @@ +import importlib +import os +from datetime import datetime +from typing import List + +import pytest +from fastapi.testclient import TestClient +from pydantic import BaseModel + +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.filter.filter import FilterExtension +from stac_fastapi.types import core, response_model, search +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.search import BaseSearchPostRequest + + +@pytest.fixture +def cleanup(): + old_environ = dict(os.environ) + yield + os.environ.clear() + os.environ.update(old_environ) + + +@pytest.mark.parametrize( + "validate, response_type", + [ + ("True", BaseModel), + ("False", dict), + ], +) +def test_app(validate, response_type, collection_dict, item_dict, cleanup): + + os.environ["VALIDATE_RESPONSE"] = str(validate) + importlib.reload(response_model) + importlib.reload(core) + + class MyCoreClient(core.BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> response_model.ItemCollection: + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) + + def get_search( + self, + collections: List[str] | None = None, + ids: List[str] | None = None, + bbox: List[float | int] | None = None, + datetime: str | datetime | None = None, + limit: int | None = 10, + query: str | None = None, + token: str | None = None, + fields: List[str] | None = None, + sortby: str | None = None, + intersects: str | None = None, + **kwargs, + ) -> response_model.ItemCollection: + + # FIXME: hyphen alias for filter_crs and filter_lang are currently not working + # assert kwargs.get("filter_crs") == "EPSG:4326" + # assert kwargs.get("filter_lang") == "cql-test" + + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) + + def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> response_model.Item: + return response_model.Item(**item_dict) + + def all_collections(self, **kwargs) -> response_model.Collections: + + return response_model.Collections( + collections=[response_model.Collection(**collection_dict)], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + def get_collection( + self, collection_id: str, **kwargs + ) -> response_model.Collection: + return response_model.Collection(**collection_dict) + + def item_collection( + self, + collection_id: str, + bbox: List[float | int] | None = None, + datetime: str | datetime | None = None, + limit: int = 10, + token: str = None, + **kwargs, + ) -> response_model.ItemCollection: + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) + + post_request_model = create_post_request_model([FilterExtension()]) + + test_app = StacApi( + settings=ApiSettings(), + client=MyCoreClient(post_request_model=post_request_model), + search_get_request_model=create_get_request_model([FilterExtension()]), + search_post_request_model=post_request_model, + ) + + class MockRequest: + base_url = "http://test" + app = test_app.app + + assert isinstance(MyCoreClient().landing_page(request=MockRequest()), response_type) + assert isinstance(MyCoreClient().get_collection("test"), response_type) + assert isinstance(MyCoreClient().all_collections(), response_type) + assert isinstance(MyCoreClient().get_item("test", "test"), response_type) + assert isinstance(MyCoreClient().item_collection("test"), response_type) + assert isinstance( + MyCoreClient().post_search(search.BaseSearchPostRequest()), response_type + ) + assert isinstance( + MyCoreClient().get_search( + **{"filter_crs": "EPSG:4326", "filter_lang": "cql-test"} + ), + response_type, + ) + + 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={"filter-crs": "EPSG:4326", "filter-lang": "cql-test"} + ) + post_search = client.post("/search", json={"collections": ["test"]}) + + assert landing.status_code == 200, landing.text + assert collection.status_code == 200, collection.text + assert collections.status_code == 200, collections.text + assert item.status_code == 200, item.text + assert item_collection.status_code == 200, item_collection.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..70c65dc70 --- /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: 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/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/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index c0a0d3c28..47dcb5485 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -31,6 +31,8 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" + validate_response: bool = False + class Config: """Model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 42954e8a9..f113e5128 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -7,12 +7,15 @@ import attr from fastapi import Request -from stac_pydantic import Collection, Item, ItemCollection, api +from pydantic import BaseModel +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 MimeTypes from starlette.responses import Response +from stac_fastapi.types import response_model +from stac_fastapi.types.config import Settings 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 @@ -262,8 +265,8 @@ def _landing_page( base_url: str, conformance_classes: List[str], extension_schemas: List[str], - ) -> api.LandingPage: - landing_page = api.LandingPage( + ) -> Dict[str, Any]: + landing_page = response_model.LandingPage( type="Catalog", id=self.landing_page_id, title=self.title, @@ -306,10 +309,20 @@ def _landing_page( "href": urljoin(base_url, "search"), "method": "POST", }, + { + "rel": Relations.service_desc.value, + "type": MimeTypes.geojson, + "title": "Service Description", + "href": Settings.get().openapi_url, + }, ], stac_extensions=extension_schemas, ) - return landing_page + + if isinstance(landing_page, BaseModel): + return landing_page.model_dump(mode="json") + else: + return landing_page @attr.s # type:ignore @@ -351,7 +364,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> api.LandingPage: + def landing_page(self, **kwargs) -> response_model.LandingPage: """Landing page. Called with `GET /`. @@ -361,6 +374,7 @@ def landing_page(self, **kwargs) -> api.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(), @@ -368,7 +382,11 @@ def landing_page(self, **kwargs) -> api.LandingPage: ) # Add Collections links - collections = self.all_collections(request=kwargs["request"]) + _collections = self.all_collections(request=kwargs["request"]) + if isinstance(_collections, BaseModel): + collections = _collections.model_dump(mode="json") + else: + collections = _collections for collection in collections["collections"]: landing_page["links"].append( { @@ -403,9 +421,9 @@ def landing_page(self, **kwargs) -> api.LandingPage: } ) - return landing_page + return response_model.LandingPage(**landing_page) - def conformance(self, **kwargs) -> api.ConformanceClasses: + def conformance(self, **kwargs) -> response_model.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -413,12 +431,12 @@ def conformance(self, **kwargs) -> api.ConformanceClasses: Returns: Conformance classes which the server conforms to. """ - return api.ConformanceClasses(conformsTo=self.conformance_classes()) + return response_model.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -445,7 +463,7 @@ def get_search( sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -456,7 +474,9 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item: + def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> response_model.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -471,7 +491,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item: ... @abc.abstractmethod - def all_collections(self, **kwargs) -> api.Collections: + def all_collections(self, **kwargs) -> response_model.Collections: """Get all available collections. Called with `GET /collections`. @@ -482,7 +502,7 @@ def all_collections(self, **kwargs) -> api.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> api.Collection: + def get_collection(self, collection_id: str, **kwargs) -> response_model.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -504,7 +524,7 @@ def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -549,7 +569,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) -> api.LandingPage: + async def landing_page(self, **kwargs) -> response_model.LandingPage: """Landing page. Called with `GET /`. @@ -559,12 +579,19 @@ async def landing_page(self, **kwargs) -> api.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(), extension_schemas=[], ) - collections = await self.all_collections(request=kwargs["request"]) + + # Add Collections links + _collections = await self.all_collections(request=kwargs["request"]) + if isinstance(_collections, BaseModel): + collections = _collections.model_dump(mode="json") + else: + collections = _collections for collection in collections["collections"]: landing_page["links"].append( { @@ -599,9 +626,9 @@ async def landing_page(self, **kwargs) -> api.LandingPage: } ) - return landing_page + return response_model.LandingPage(**landing_page) - async def conformance(self, **kwargs) -> api.ConformanceClasses: + async def conformance(self, **kwargs) -> response_model.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -609,12 +636,12 @@ async def conformance(self, **kwargs) -> api.ConformanceClasses: Returns: Conformance classes which the server conforms to. """ - return api.ConformanceClasses(conformsTo=self.conformance_classes()) + return response_model.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod async def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -641,7 +668,7 @@ async def get_search( sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -652,7 +679,9 @@ async def get_search( ... @abc.abstractmethod - async def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item: + async def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> response_model.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -667,7 +696,7 @@ async def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> api.Collections: + async def all_collections(self, **kwargs) -> response_model.Collections: """Get all available collections. Called with `GET /collections`. @@ -678,7 +707,9 @@ async def all_collections(self, **kwargs) -> api.Collections: ... @abc.abstractmethod - async def get_collection(self, collection_id: str, **kwargs) -> api.Collection: + async def get_collection( + self, collection_id: str, **kwargs + ) -> response_model.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -700,7 +731,7 @@ async def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> api.ItemCollection: + ) -> response_model.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` diff --git a/stac_fastapi/types/stac_fastapi/types/response_model.py b/stac_fastapi/types/stac_fastapi/types/response_model.py new file mode 100644 index 000000000..b74f24bff --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/response_model.py @@ -0,0 +1,26 @@ +"""Response models for STAC FastAPI. +Depending on settings models are either TypeDicts or Pydantic models.""" + +from stac_pydantic import api + +from stac_fastapi.types import stac +from stac_fastapi.types.config import ApiSettings + +settings = ApiSettings() + +if settings.validate_response: + response_model = api +else: + response_model = stac + + +LandingPage = response_model.LandingPage +Collection = response_model.Collection +Collections = response_model.Collections +Item = response_model.Item +ItemCollection = response_model.ItemCollection +try: + Conformance = response_model.Conformance +except AttributeError: + # TODO: class name needs to be fixed in stac_pydantic + Conformance = response_model.ConformanceClasses diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py new file mode 100644 index 000000000..089e86614 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -0,0 +1,83 @@ +"""STAC types.""" + +import sys +from typing import Any, Dict, List, Literal, Optional, Union + +# 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 +# differentiate required and optional fields when subclassed. +if sys.version_info < (3, 9, 2): + from typing_extensions import TypedDict +else: + from typing import TypedDict + +NumType = Union[float, int] + + +class Catalog(TypedDict, total=False): + """STAC Catalog.""" + + type: str + stac_version: str + stac_extensions: Optional[List[str]] + id: str + title: Optional[str] + description: str + links: List[Dict[str, Any]] + + +class LandingPage(Catalog, total=False): + """STAC Landing Page.""" + + conformsTo: List[str] + + +class Conformance(TypedDict): + """STAC Conformance Classes.""" + + conformsTo: List[str] + + +class Collection(Catalog, total=False): + """STAC Collection.""" + + keywords: List[str] + license: str + providers: List[Dict[str, Any]] + extent: Dict[str, Any] + summaries: Dict[str, Any] + assets: Dict[str, Any] + + +class Item(TypedDict, total=False): + """STAC Item.""" + + type: Literal["Feature"] + stac_version: str + stac_extensions: Optional[List[str]] + id: str + geometry: Dict[str, Any] + bbox: List[NumType] + properties: Dict[str, Any] + links: List[Dict[str, Any]] + assets: Dict[str, Any] + collection: str + + +class ItemCollection(TypedDict, total=False): + """STAC Item Collection.""" + + type: Literal["FeatureCollection"] + features: List[Item] + links: List[Dict[str, Any]] + context: Optional[Dict[str, int]] + + +class Collections(TypedDict, total=False): + """All collections endpoint. + https://github.com/radiantearth/stac-api-spec/tree/master/collections + """ + + collections: List[Collection] + links: List[Dict[str, Any]] diff --git a/stac_fastapi/types/tests/test_response_model.py b/stac_fastapi/types/tests/test_response_model.py new file mode 100644 index 000000000..3be6b24be --- /dev/null +++ b/stac_fastapi/types/tests/test_response_model.py @@ -0,0 +1,40 @@ +import importlib +import os + +import pytest +from pydantic import BaseModel + +from stac_fastapi.types import response_model + + +@pytest.fixture +def cleanup(): + old_environ = dict(os.environ) + yield + os.environ.clear() + os.environ.update(old_environ) + + +@pytest.mark.parametrize( + "validate, response_type", + [ + ("True", BaseModel), + ("False", dict), + ], +) +def test_response_model(validate, response_type, cleanup): + + os.environ["VALIDATE_RESPONSE"] = str(validate) + importlib.reload(response_model) + + landing_page = response_model.LandingPage( + id="test", + description="test", + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "service-desc"}, + ], + ) + + assert isinstance(landing_page, response_type) From b52f2163a99cdaac55f93e7f1246557c776d1e59 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 16:14:32 -0500 Subject: [PATCH 17/37] lint and format with ruff --- .pre-commit-config.yaml | 13 +++++++------ pyproject.toml | 11 +++++++---- stac_fastapi/api/stac_fastapi/api/openapi.py | 4 +--- stac_fastapi/api/tests/test_api.py | 18 ++++++++++++------ stac_fastapi/api/tests/test_app.py | 4 ---- stac_fastapi/api/tests/test_models.py | 1 - .../extensions/core/transaction.py | 4 +--- .../third_party/bulk_transactions.py | 4 +--- stac_fastapi/types/stac_fastapi/types/core.py | 12 +++--------- .../types/stac_fastapi/types/requests.py | 4 +--- .../types/tests/test_response_model.py | 1 - 11 files changed, 33 insertions(+), 43 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51588b36c..c1d968eda 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,11 @@ 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: 24.1.1 - hooks: - - id: black + - id: ruff-format + # - repo: https://github.com/psf/black + # rev: 24.1.1 + # hooks: + # - id: black diff --git a/pyproject.toml b/pyproject.toml index 08a267798..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", "py312"] +[tool.ruff.format] +quote-style = "double" diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index 84d72dab1..ab90ce425 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -44,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/test_api.py b/stac_fastapi/api/tests/test_api.py index e47ec9747..96270baf9 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -110,17 +110,23 @@ def test_add_route_dependencies_after_building_api(self, collection, item): class DummyCoreClient(core.BaseCoreClient): - def all_collections(self, *args, **kwargs): ... + def all_collections(self, *args, **kwargs): + ... - def get_collection(self, *args, **kwargs): ... + def get_collection(self, *args, **kwargs): + ... - def get_item(self, *args, **kwargs): ... + def get_item(self, *args, **kwargs): + ... - def get_search(self, *args, **kwargs): ... + def get_search(self, *args, **kwargs): + ... - def post_search(self, *args, **kwargs): ... + def post_search(self, *args, **kwargs): + ... - def item_collection(self, *args, **kwargs): ... + def item_collection(self, *args, **kwargs): + ... class DummyTransactionsClient(core.BaseTransactionsClient): diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index a560a8600..74f111fbe 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -31,7 +31,6 @@ def cleanup(): ], ) def test_app(validate, response_type, collection_dict, item_dict, cleanup): - os.environ["VALIDATE_RESPONSE"] = str(validate) importlib.reload(response_model) importlib.reload(core) @@ -58,7 +57,6 @@ def get_search( intersects: str | None = None, **kwargs, ) -> response_model.ItemCollection: - # FIXME: hyphen alias for filter_crs and filter_lang are currently not working # assert kwargs.get("filter_crs") == "EPSG:4326" # assert kwargs.get("filter_lang") == "cql-test" @@ -73,7 +71,6 @@ def get_item( return response_model.Item(**item_dict) def all_collections(self, **kwargs) -> response_model.Collections: - return response_model.Collections( collections=[response_model.Collection(**collection_dict)], links=[ @@ -130,7 +127,6 @@ class MockRequest: ) with TestClient(test_app.app) as client: - landing = client.get("/") collection = client.get("/collections/test") collections = client.get("/collections") diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index 70c65dc70..333080fee 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -10,7 +10,6 @@ def test_create_get_request_model(): - extensions = [FilterExtension()] request_model = create_get_request_model(extensions, BaseSearchGetRequest) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index c5abd9add..8e30ec872 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -135,9 +135,7 @@ def register_delete_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["DELETE"], - endpoint=create_async_endpoint( - self.client.delete_collection, CollectionUri - ), + endpoint=create_async_endpoint(self.client.delete_collection, CollectionUri), ) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py index 7033bbd89..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 @@ -110,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/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index f113e5128..8efe182d3 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -415,9 +415,7 @@ def landing_page(self, **kwargs) -> response_model.LandingPage: "rel": "service-doc", "type": "text/html", "title": "OpenAPI service documentation", - "href": urljoin( - str(request.base_url), request.app.docs_url.lstrip("/") - ), + "href": urljoin(str(request.base_url), request.app.docs_url.lstrip("/")), } ) @@ -474,9 +472,7 @@ def get_search( ... @abc.abstractmethod - def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> response_model.Item: + def get_item(self, item_id: str, collection_id: str, **kwargs) -> response_model.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -620,9 +616,7 @@ async def landing_page(self, **kwargs) -> response_model.LandingPage: "rel": "service-doc", "type": "text/html", "title": "OpenAPI service documentation", - "href": urljoin( - str(request.base_url), request.app.docs_url.lstrip("/") - ), + "href": urljoin(str(request.base_url), request.app.docs_url.lstrip("/")), } ) 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/tests/test_response_model.py b/stac_fastapi/types/tests/test_response_model.py index 3be6b24be..3086c1352 100644 --- a/stac_fastapi/types/tests/test_response_model.py +++ b/stac_fastapi/types/tests/test_response_model.py @@ -23,7 +23,6 @@ def cleanup(): ], ) def test_response_model(validate, response_type, cleanup): - os.environ["VALIDATE_RESPONSE"] = str(validate) importlib.reload(response_model) From f2f93745bffae14177de52b742994830f1f12e2f Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 16:29:11 -0500 Subject: [PATCH 18/37] remove comment --- .pre-commit-config.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1d968eda..68c3b8567 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,3 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - # - repo: https://github.com/psf/black - # rev: 24.1.1 - # hooks: - # - id: black From 273f8191761fe44527ddf9e25fcd480124aa1f8f Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 16:31:03 -0500 Subject: [PATCH 19/37] update change log --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index d1fad03f3..ef6012a4b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,10 @@ ## Changes * Update to pydantic v2 and stac_pydantic v3 -* Removed internal Stac, Search and Operator Types in favor of stac_pydantic Types +* Removed internal Search and Operator Types in favor of stac_pydantic Types +* Add switch to choose between TypeDict and StacPydantic response models +* Add support for Python 3.12 +* Replace Black with Ruff Format ## [2.4.9] - 2023-11-17 From dc67a4d6742c5dd8d38da1897854885b3e9bac91 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 20:01:20 -0500 Subject: [PATCH 20/37] use Optional not | None --- stac_fastapi/api/tests/test_app.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 74f111fbe..9056ed968 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,7 +1,7 @@ import importlib import os from datetime import datetime -from typing import List +from typing import List, Optional, Union import pytest from fastapi.testclient import TestClient @@ -45,16 +45,16 @@ def post_search( def get_search( self, - collections: List[str] | None = None, - ids: List[str] | None = None, - bbox: List[float | int] | None = None, - datetime: str | datetime | None = None, - limit: int | None = 10, - query: str | None = None, - token: str | None = None, - fields: List[str] | None = None, - sortby: str | None = None, - intersects: str | None = None, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[Union[float, int]]] = None, + datetime: Optional[Union[str, datetime]] = 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, ) -> response_model.ItemCollection: # FIXME: hyphen alias for filter_crs and filter_lang are currently not working From 7208dd7320b74c8a1df3394324f95a4119ffc8a0 Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 23 Feb 2024 20:02:41 -0500 Subject: [PATCH 21/37] use Optional not | None --- stac_fastapi/api/tests/test_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 9056ed968..62b5693a6 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -88,8 +88,8 @@ def get_collection( def item_collection( self, collection_id: str, - bbox: List[float | int] | None = None, - datetime: str | datetime | None = None, + bbox: Optional[List[Union[float, int]]] = None, + datetime: Optional[Union[str, datetime]] = None, limit: int = 10, token: str = None, **kwargs, From e24351ad2c182a790128d11423cc05870df7dd9f Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Wed, 3 Apr 2024 22:57:15 -0400 Subject: [PATCH 22/37] update dependencies --- stac_fastapi/api/setup.py | 13 +++++++++---- stac_fastapi/api/tests/test_app.py | 8 ++++++-- stac_fastapi/extensions/setup.py | 16 +++++++++++----- stac_fastapi/types/setup.py | 11 +++++++++-- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index cb9097eca..439e55b72 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -1,16 +1,20 @@ """stac_fastapi: api module.""" +from distutils.util import convert_path + from setuptools import find_namespace_packages, setup +main_ns = {} +ver_path = convert_path("stac_fastapi/api/version.py") +with open(ver_path) as ver_file: + exec(ver_file.read(), main_ns) + with open("README.md") as f: desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]>=2", - "stac_pydantic>=3", "brotli_asgi", - "stac-fastapi.types", + f"stac-fastapi.types=={main_ns['__version__']}", ] extra_reqs = { @@ -54,4 +58,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, + version=main_ns["__version__"], ) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 62b5693a6..2878e2ac4 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,3 +1,6 @@ +"""Implement all read_only methods of BaseCoreClient +and test all GET endpoints of the API""" + import importlib import os from datetime import datetime @@ -30,8 +33,9 @@ def cleanup(): ("False", dict), ], ) -def test_app(validate, response_type, collection_dict, item_dict, cleanup): - os.environ["VALIDATE_RESPONSE"] = str(validate) +def test_app(validate, response_type, collection_dict, item_dict, cleanup, monkeypatch): + monkeypatch.setenv("VALIDATE_RESPONSE", validate) + importlib.reload(response_model) importlib.reload(core) diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index a22d8815f..c332ccb99 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -1,16 +1,21 @@ """stac_fastapi: extensions module.""" +from distutils.util import convert_path + from setuptools import find_namespace_packages, setup +main_ns = {} +ver_path = convert_path("stac_fastapi/extensions/version.py") +with open(ver_path) as ver_file: + exec(ver_file.read(), main_ns) + + with open("README.md") as f: desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]>=2", - "stac_pydantic>=3", - "stac-fastapi.types", - "stac-fastapi.api", + f"stac-fastapi.types=={main_ns['__version__']}", + f"stac-fastapi.api=={main_ns['__version__']}", ] extra_reqs = { @@ -52,4 +57,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, + version=main_ns["__version__"], ) diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 7717f3a69..cf9cb8941 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -1,14 +1,20 @@ """stac_fastapi: types module.""" +from distutils.util import convert_path + from setuptools import find_namespace_packages, setup +main_ns = {} +ver_path = convert_path("stac_fastapi/types/version.py") +with open(ver_path) as ver_file: + exec(ver_file.read(), main_ns) + with open("README.md") as f: desc = f.read() install_requires = [ "fastapi>=0.100.0", - "attrs", - "pydantic[dotenv]>=2", + "attrs>=23.2.0", "pydantic-settings>=2", "stac_pydantic>=3", "pystac==1.*", @@ -54,4 +60,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, + version=main_ns["__version__"], ) From 5bdd6158477f70e03265edb5541c42cf92e3b61c Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Thu, 4 Apr 2024 16:23:01 -0400 Subject: [PATCH 23/37] hard code versions and address other comments --- stac_fastapi/api/setup.py | 11 +- stac_fastapi/api/tests/conftest.py | 67 +++++++++ stac_fastapi/api/tests/test_app.py | 140 ++++++++---------- stac_fastapi/api/tests/test_models.py | 3 +- stac_fastapi/extensions/setup.py | 13 +- stac_fastapi/types/setup.py | 9 +- .../types/stac_fastapi/types/config.py | 8 +- stac_fastapi/types/stac_fastapi/types/core.py | 6 +- .../stac_fastapi/types/response_model.py | 1 + 9 files changed, 144 insertions(+), 114 deletions(-) diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 439e55b72..fcd37a775 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -1,20 +1,13 @@ """stac_fastapi: api module.""" -from distutils.util import convert_path - from setuptools import find_namespace_packages, setup -main_ns = {} -ver_path = convert_path("stac_fastapi/api/version.py") -with open(ver_path) as ver_file: - exec(ver_file.read(), main_ns) - with open("README.md") as f: desc = f.read() install_requires = [ "brotli_asgi", - f"stac-fastapi.types=={main_ns['__version__']}", + "stac-fastapi.types==2.4.9", ] extra_reqs = { @@ -58,5 +51,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version=main_ns["__version__"], + version="2.4.9", ) diff --git a/stac_fastapi/api/tests/conftest.py b/stac_fastapi/api/tests/conftest.py index 603e89cea..6fa1471d3 100644 --- a/stac_fastapi/api/tests/conftest.py +++ b/stac_fastapi/api/tests/conftest.py @@ -1,7 +1,14 @@ +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, response_model +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() @@ -53,3 +60,63 @@ def item(_item: Item): @pytest.fixture def item_dict(_item: Item): return _item.model_dump(mode="json") + + +@pytest.fixture +def TestCoreClient(collection_dict, item_dict): + class CoreClient(core.BaseCoreClient): + def post_search( + self, search_request: BaseSearchPostRequest, **kwargs + ) -> response_model.ItemCollection: + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.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, + ) -> response_model.ItemCollection: + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) + + def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> response_model.Item: + return response_model.Item(**item_dict) + + def all_collections(self, **kwargs) -> response_model.Collections: + return response_model.Collections( + collections=[response_model.Collection(**collection_dict)], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + def get_collection( + self, collection_id: str, **kwargs + ) -> response_model.Collection: + return response_model.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, + ) -> response_model.ItemCollection: + return response_model.ItemCollection( + type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) + + return CoreClient diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 2878e2ac4..ae5a859d2 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,8 +1,4 @@ -"""Implement all read_only methods of BaseCoreClient -and test all GET endpoints of the API""" - import importlib -import os from datetime import datetime from typing import List, Optional, Union @@ -15,17 +11,10 @@ from stac_fastapi.extensions.core.filter.filter import FilterExtension from stac_fastapi.types import core, response_model, search from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import NumType from stac_fastapi.types.search import BaseSearchPostRequest -@pytest.fixture -def cleanup(): - old_environ = dict(os.environ) - yield - os.environ.clear() - os.environ.update(old_environ) - - @pytest.mark.parametrize( "validate, response_type", [ @@ -33,16 +22,48 @@ def cleanup(): ("False", dict), ], ) -def test_app(validate, response_type, collection_dict, item_dict, cleanup, monkeypatch): +def test_client_response_type(validate, response_type, TestCoreClient, monkeypatch): + """Test for correct response type when VALIDATE_RESPONSE is set.""" monkeypatch.setenv("VALIDATE_RESPONSE", validate) importlib.reload(response_model) importlib.reload(core) - class MyCoreClient(core.BaseCoreClient): + test_app = StacApi( + settings=ApiSettings(), + client=TestCoreClient(), + ) + + class MockRequest: + base_url = "http://test" + app = test_app.app + + assert isinstance(TestCoreClient().landing_page(request=MockRequest()), response_type) + assert isinstance(TestCoreClient().get_collection("test"), response_type) + assert isinstance(TestCoreClient().all_collections(), response_type) + assert isinstance(TestCoreClient().get_item("test", "test"), response_type) + assert isinstance(TestCoreClient().item_collection("test"), response_type) + assert isinstance( + TestCoreClient().post_search(search.BaseSearchPostRequest()), response_type + ) + assert isinstance( + TestCoreClient().get_search(), + response_type, + ) + + +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 ) -> response_model.ItemCollection: + search_request.collections = ["test"] + search_request.filter = {} + search_request.filter_crs = "EPSG:4326" + search_request.filter_lang = "cql2-text" + return response_model.ItemCollection( type="FeatureCollection", features=[response_model.Item(**item_dict)] ) @@ -51,53 +72,28 @@ def get_search( self, collections: Optional[List[str]] = None, ids: Optional[List[str]] = None, - bbox: Optional[List[Union[float, int]]] = None, + bbox: Optional[List[NumType]] = None, + intersects: Optional[str] = None, datetime: Optional[Union[str, datetime]] = 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, + filter: Optional[str] = None, + filter_crs: Optional[str] = None, + filter_lang: Optional[str] = None, **kwargs, ) -> response_model.ItemCollection: - # FIXME: hyphen alias for filter_crs and filter_lang are currently not working - # assert kwargs.get("filter_crs") == "EPSG:4326" - # assert kwargs.get("filter_lang") == "cql-test" + # Check if all filter parameters are passed correctly - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] - ) + assert filter == "TEST" - def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> response_model.Item: - return response_model.Item(**item_dict) - - def all_collections(self, **kwargs) -> response_model.Collections: - return response_model.Collections( - collections=[response_model.Collection(**collection_dict)], - links=[ - {"href": "test", "rel": "root"}, - {"href": "test", "rel": "self"}, - {"href": "test", "rel": "parent"}, - ], - ) + # 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` - def get_collection( - self, collection_id: str, **kwargs - ) -> response_model.Collection: - return response_model.Collection(**collection_dict) + # assert filter_crs == "EPSG:4326" + # assert filter_lang == "cql2-text" - 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, - ) -> response_model.ItemCollection: return response_model.ItemCollection( type="FeatureCollection", features=[response_model.Item(**item_dict)] ) @@ -106,30 +102,11 @@ def item_collection( test_app = StacApi( settings=ApiSettings(), - client=MyCoreClient(post_request_model=post_request_model), + client=FilterClient(post_request_model=post_request_model), search_get_request_model=create_get_request_model([FilterExtension()]), search_post_request_model=post_request_model, ) - class MockRequest: - base_url = "http://test" - app = test_app.app - - assert isinstance(MyCoreClient().landing_page(request=MockRequest()), response_type) - assert isinstance(MyCoreClient().get_collection("test"), response_type) - assert isinstance(MyCoreClient().all_collections(), response_type) - assert isinstance(MyCoreClient().get_item("test", "test"), response_type) - assert isinstance(MyCoreClient().item_collection("test"), response_type) - assert isinstance( - MyCoreClient().post_search(search.BaseSearchPostRequest()), response_type - ) - assert isinstance( - MyCoreClient().get_search( - **{"filter_crs": "EPSG:4326", "filter_lang": "cql-test"} - ), - response_type, - ) - with TestClient(test_app.app) as client: landing = client.get("/") collection = client.get("/collections/test") @@ -140,9 +117,22 @@ class MockRequest: params={"limit": 10}, ) get_search = client.get( - "/search", params={"filter-crs": "EPSG:4326", "filter-lang": "cql-test"} + "/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", + }, ) - post_search = client.post("/search", json={"collections": ["test"]}) assert landing.status_code == 200, landing.text assert collection.status_code == 200, collection.text diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index 333080fee..cbff0f53d 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -26,7 +26,8 @@ def test_create_get_request_model(): datetime="2020-01-01T00:00:00Z", limit=10, filter="test==test", - # FIXME: hyphen aliases are not properly working + # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638 + # hyphen aliases are not properly working # **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"}, ) diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index c332ccb99..700cf6061 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -1,21 +1,14 @@ """stac_fastapi: extensions module.""" -from distutils.util import convert_path from setuptools import find_namespace_packages, setup -main_ns = {} -ver_path = convert_path("stac_fastapi/extensions/version.py") -with open(ver_path) as ver_file: - exec(ver_file.read(), main_ns) - - with open("README.md") as f: desc = f.read() install_requires = [ - f"stac-fastapi.types=={main_ns['__version__']}", - f"stac-fastapi.api=={main_ns['__version__']}", + "stac-fastapi.types==2.4.9", + "stac-fastapi.api==2.4.9", ] extra_reqs = { @@ -57,5 +50,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version=main_ns["__version__"], + version="2.4.9", ) diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index cf9cb8941..b00ae08e5 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -1,14 +1,7 @@ """stac_fastapi: types module.""" -from distutils.util import convert_path - from setuptools import find_namespace_packages, setup -main_ns = {} -ver_path = convert_path("stac_fastapi/types/version.py") -with open(ver_path) as ver_file: - exec(ver_file.read(), main_ns) - with open("README.md") as f: desc = f.read() @@ -60,5 +53,5 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version=main_ns["__version__"], + version="2.4.9", ) diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 47dcb5485..203adf4a1 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -2,7 +2,7 @@ from typing import Optional, Set -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class ApiSettings(BaseSettings): @@ -33,11 +33,7 @@ class ApiSettings(BaseSettings): validate_response: bool = False - 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/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index fed0f5b48..1c25aacc4 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -453,13 +453,9 @@ def get_search( 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, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - intersects: Optional[str] = None, **kwargs, ) -> response_model.ItemCollection: """Cross catalog search (GET). diff --git a/stac_fastapi/types/stac_fastapi/types/response_model.py b/stac_fastapi/types/stac_fastapi/types/response_model.py index b74f24bff..76d266a81 100644 --- a/stac_fastapi/types/stac_fastapi/types/response_model.py +++ b/stac_fastapi/types/stac_fastapi/types/response_model.py @@ -23,4 +23,5 @@ Conformance = response_model.Conformance except AttributeError: # TODO: class name needs to be fixed in stac_pydantic + # stac-utils/stac-pydantic#136 Conformance = response_model.ConformanceClasses From eea9c8227705a35425b7e2f864946a787f9ecaef Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Thu, 4 Apr 2024 21:24:55 -0400 Subject: [PATCH 24/37] remove response_model module, update openapi schema --- stac_fastapi/api/stac_fastapi/api/app.py | 84 ++++++++-- stac_fastapi/api/tests/conftest.py | 38 ++--- stac_fastapi/api/tests/test_app.py | 149 ++++++++++++------ .../types/stac_fastapi/types/config.py | 2 - stac_fastapi/types/stac_fastapi/types/core.py | 106 ++++++------- .../stac_fastapi/types/response_model.py | 27 ---- .../types/tests/test_response_model.py | 39 ----- 7 files changed, 234 insertions(+), 211 deletions(-) delete mode 100644 stac_fastapi/types/stac_fastapi/types/response_model.py delete mode 100644 stac_fastapi/types/tests/test_response_model.py diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 1ecc7b5ba..7db477526 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -7,10 +7,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.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 @@ -127,8 +127,16 @@ def register_landing_page(self): name="Landing Page", path="/", response_model=( - LandingPage if self.settings.enable_response_models else None + 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, @@ -148,8 +156,16 @@ def register_conformance_classes(self): name="Conformance Classes", path="/conformance", response_model=( - ConformanceClasses if self.settings.enable_response_models else None + 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, @@ -168,7 +184,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, @@ -189,10 +213,18 @@ def register_post_search(self): name="Search", path="/search", response_model=( - (ItemCollection if not fields_ext else None) + (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, @@ -213,10 +245,18 @@ def register_get_search(self): name="Search", path="/search", response_model=( - (ItemCollection if not fields_ext else None) + (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, @@ -238,6 +278,14 @@ def register_get_collections(self): 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 +304,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, @@ -286,8 +344,16 @@ def register_get_item_collection(self): name="Get ItemCollection", path="/collections/{collection_id}/items", response_model=( - ItemCollection if self.settings.enable_response_models else None + 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/tests/conftest.py b/stac_fastapi/api/tests/conftest.py index 6fa1471d3..cd5049736 100644 --- a/stac_fastapi/api/tests/conftest.py +++ b/stac_fastapi/api/tests/conftest.py @@ -5,7 +5,7 @@ from stac_pydantic import Collection, Item from stac_pydantic.api.utils import link_factory -from stac_fastapi.types import core, response_model +from stac_fastapi.types import core, stac from stac_fastapi.types.core import NumType from stac_fastapi.types.search import BaseSearchPostRequest @@ -67,9 +67,9 @@ def TestCoreClient(collection_dict, item_dict): class CoreClient(core.BaseCoreClient): def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> response_model.ItemCollection: - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) -> stac.ItemCollection: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] ) def get_search( @@ -81,19 +81,17 @@ def get_search( datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, **kwargs, - ) -> response_model.ItemCollection: - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) -> stac.ItemCollection: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] ) - def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> response_model.Item: - return response_model.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) -> response_model.Collections: - return response_model.Collections( - collections=[response_model.Collection(**collection_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"}, @@ -101,10 +99,8 @@ def all_collections(self, **kwargs) -> response_model.Collections: ], ) - def get_collection( - self, collection_id: str, **kwargs - ) -> response_model.Collection: - return response_model.Collection(**collection_dict) + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: + return stac.Collection(**collection_dict) def item_collection( self, @@ -114,9 +110,9 @@ def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> response_model.ItemCollection: - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] + ) -> stac.ItemCollection: + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] ) return CoreClient diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index ae5a859d2..9b4e0e828 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,55 +1,113 @@ -import importlib from datetime import datetime from typing import List, Optional, Union import pytest from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import ValidationError +from stac_pydantic import api -from stac_fastapi.api.app import StacApi +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 core, response_model, search +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 -@pytest.mark.parametrize( - "validate, response_type", - [ - ("True", BaseModel), - ("False", dict), - ], -) -def test_client_response_type(validate, response_type, TestCoreClient, monkeypatch): - """Test for correct response type when VALIDATE_RESPONSE is set.""" - monkeypatch.setenv("VALIDATE_RESPONSE", validate) +def test_client_response_type(TestCoreClient): + """Test all GET endpoints. Verify that responses are valid STAC items.""" - importlib.reload(response_model) - importlib.reload(core) - - test_app = StacApi( + test_app = app.StacApi( settings=ApiSettings(), client=TestCoreClient(), ) - class MockRequest: - base_url = "http://test" - app = test_app.app - - assert isinstance(TestCoreClient().landing_page(request=MockRequest()), response_type) - assert isinstance(TestCoreClient().get_collection("test"), response_type) - assert isinstance(TestCoreClient().all_collections(), response_type) - assert isinstance(TestCoreClient().get_item("test", "test"), response_type) - assert isinstance(TestCoreClient().item_collection("test"), response_type) - assert isinstance( - TestCoreClient().post_search(search.BaseSearchPostRequest()), response_type + 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(), ) - assert isinstance( - TestCoreClient().get_search(), - response_type, + + 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): @@ -58,14 +116,14 @@ def test_filter_extension(TestCoreClient, item_dict): class FilterClient(TestCoreClient): def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: search_request.collections = ["test"] search_request.filter = {} search_request.filter_crs = "EPSG:4326" search_request.filter_lang = "cql2-text" - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] ) def get_search( @@ -80,7 +138,7 @@ def get_search( filter_crs: Optional[str] = None, filter_lang: Optional[str] = None, **kwargs, - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: # Check if all filter parameters are passed correctly assert filter == "TEST" @@ -94,13 +152,13 @@ def get_search( # assert filter_crs == "EPSG:4326" # assert filter_lang == "cql2-text" - return response_model.ItemCollection( - type="FeatureCollection", features=[response_model.Item(**item_dict)] + return stac.ItemCollection( + type="FeatureCollection", features=[stac.Item(**item_dict)] ) post_request_model = create_post_request_model([FilterExtension()]) - test_app = StacApi( + test_app = app.StacApi( settings=ApiSettings(), client=FilterClient(post_request_model=post_request_model), search_get_request_model=create_get_request_model([FilterExtension()]), @@ -108,14 +166,6 @@ def get_search( ) 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={ @@ -134,10 +184,5 @@ def get_search( }, ) - assert landing.status_code == 200, landing.text - assert collection.status_code == 200, collection.text - assert collections.status_code == 200, collections.text - assert item.status_code == 200, item.text - assert item_collection.status_code == 200, item_collection.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/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 203adf4a1..f3fd4d655 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -31,8 +31,6 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" - validate_response: bool = False - model_config = SettingsConfigDict(env_file=".env", extra="allow") diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 1c25aacc4..bfa77772b 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -7,14 +7,13 @@ import attr from fastapi import Request -from pydantic import BaseModel 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 MimeTypes from starlette.responses import Response -from stac_fastapi.types import response_model +from stac_fastapi.types import stac from stac_fastapi.types.config import Settings from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.extension import ApiExtension @@ -265,8 +264,8 @@ def _landing_page( base_url: str, conformance_classes: List[str], extension_schemas: List[str], - ) -> Dict[str, Any]: - landing_page = response_model.LandingPage( + ) -> stac.LandingPage: + landing_page = stac.LandingPage( type="Catalog", id=self.landing_page_id, title=self.title, @@ -276,42 +275,42 @@ 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", }, { "rel": Relations.service_desc.value, - "type": MimeTypes.geojson, + "type": MimeTypes.geojson.value, "title": "Service Description", "href": Settings.get().openapi_url, }, @@ -319,10 +318,7 @@ def _landing_page( stac_extensions=extension_schemas, ) - if isinstance(landing_page, BaseModel): - return landing_page.model_dump(mode="json") - else: - return landing_page + return landing_page @attr.s # type:ignore @@ -364,7 +360,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> response_model.LandingPage: + def landing_page(self, **kwargs) -> stac.LandingPage: """Landing page. Called with `GET /`. @@ -383,10 +379,8 @@ def landing_page(self, **kwargs) -> response_model.LandingPage: # Add Collections links _collections = self.all_collections(request=kwargs["request"]) - if isinstance(_collections, BaseModel): - collections = _collections.model_dump(mode="json") - else: - collections = _collections + collections = _collections + for collection in collections["collections"]: landing_page["links"].append( { @@ -400,8 +394,8 @@ def landing_page(self, **kwargs) -> response_model.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": urljoin( str(request.base_url), request.app.openapi_url.lstrip("/") @@ -412,16 +406,16 @@ def landing_page(self, **kwargs) -> response_model.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": urljoin(str(request.base_url), request.app.docs_url.lstrip("/")), } ) - return response_model.LandingPage(**landing_page) + return stac.LandingPage(**landing_page) - def conformance(self, **kwargs) -> response_model.Conformance: + def conformance(self, **kwargs) -> stac.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -429,12 +423,12 @@ def conformance(self, **kwargs) -> response_model.Conformance: Returns: Conformance classes which the server conforms to. """ - return response_model.Conformance(conformsTo=self.conformance_classes()) + return stac.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -457,7 +451,7 @@ def get_search( datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, **kwargs, - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -468,7 +462,7 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> response_model.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}`. @@ -483,7 +477,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> response_model ... @abc.abstractmethod - def all_collections(self, **kwargs) -> response_model.Collections: + def all_collections(self, **kwargs) -> stac.Collections: """Get all available collections. Called with `GET /collections`. @@ -494,7 +488,7 @@ def all_collections(self, **kwargs) -> response_model.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> response_model.Collection: + def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -516,7 +510,7 @@ def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -561,7 +555,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) -> response_model.LandingPage: + async def landing_page(self, **kwargs) -> stac.LandingPage: """Landing page. Called with `GET /`. @@ -580,10 +574,8 @@ async def landing_page(self, **kwargs) -> response_model.LandingPage: # Add Collections links _collections = await self.all_collections(request=kwargs["request"]) - if isinstance(_collections, BaseModel): - collections = _collections.model_dump(mode="json") - else: - collections = _collections + collections = _collections + for collection in collections["collections"]: landing_page["links"].append( { @@ -597,8 +589,8 @@ async def landing_page(self, **kwargs) -> response_model.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": urljoin( str(request.base_url), request.app.openapi_url.lstrip("/") @@ -609,16 +601,16 @@ async def landing_page(self, **kwargs) -> response_model.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": urljoin(str(request.base_url), request.app.docs_url.lstrip("/")), } ) - return response_model.LandingPage(**landing_page) + return stac.LandingPage(**landing_page) - async def conformance(self, **kwargs) -> response_model.Conformance: + async def conformance(self, **kwargs) -> stac.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -626,12 +618,12 @@ async def conformance(self, **kwargs) -> response_model.Conformance: Returns: Conformance classes which the server conforms to. """ - return response_model.Conformance(conformsTo=self.conformance_classes()) + return stac.Conformance(conformsTo=self.conformance_classes()) @abc.abstractmethod async def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -650,15 +642,11 @@ async def get_search( 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, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - intersects: Optional[str] = None, **kwargs, - ) -> response_model.ItemCollection: + ) -> stac.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -669,9 +657,7 @@ async def get_search( ... @abc.abstractmethod - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> response_model.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}`. @@ -686,7 +672,7 @@ async def get_item( ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> response_model.Collections: + async def all_collections(self, **kwargs) -> stac.Collections: """Get all available collections. Called with `GET /collections`. @@ -697,9 +683,7 @@ async def all_collections(self, **kwargs) -> response_model.Collections: ... @abc.abstractmethod - async def get_collection( - self, collection_id: str, **kwargs - ) -> response_model.Collection: + async def get_collection(self, collection_id: str, **kwargs) -> stac.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -721,7 +705,7 @@ async def item_collection( limit: int = 10, token: str = None, **kwargs, - ) -> response_model.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/response_model.py b/stac_fastapi/types/stac_fastapi/types/response_model.py deleted file mode 100644 index 76d266a81..000000000 --- a/stac_fastapi/types/stac_fastapi/types/response_model.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Response models for STAC FastAPI. -Depending on settings models are either TypeDicts or Pydantic models.""" - -from stac_pydantic import api - -from stac_fastapi.types import stac -from stac_fastapi.types.config import ApiSettings - -settings = ApiSettings() - -if settings.validate_response: - response_model = api -else: - response_model = stac - - -LandingPage = response_model.LandingPage -Collection = response_model.Collection -Collections = response_model.Collections -Item = response_model.Item -ItemCollection = response_model.ItemCollection -try: - Conformance = response_model.Conformance -except AttributeError: - # TODO: class name needs to be fixed in stac_pydantic - # stac-utils/stac-pydantic#136 - Conformance = response_model.ConformanceClasses diff --git a/stac_fastapi/types/tests/test_response_model.py b/stac_fastapi/types/tests/test_response_model.py deleted file mode 100644 index 3086c1352..000000000 --- a/stac_fastapi/types/tests/test_response_model.py +++ /dev/null @@ -1,39 +0,0 @@ -import importlib -import os - -import pytest -from pydantic import BaseModel - -from stac_fastapi.types import response_model - - -@pytest.fixture -def cleanup(): - old_environ = dict(os.environ) - yield - os.environ.clear() - os.environ.update(old_environ) - - -@pytest.mark.parametrize( - "validate, response_type", - [ - ("True", BaseModel), - ("False", dict), - ], -) -def test_response_model(validate, response_type, cleanup): - os.environ["VALIDATE_RESPONSE"] = str(validate) - importlib.reload(response_model) - - landing_page = response_model.LandingPage( - id="test", - description="test", - links=[ - {"href": "test", "rel": "root"}, - {"href": "test", "rel": "self"}, - {"href": "test", "rel": "service-desc"}, - ], - ) - - assert isinstance(landing_page, response_type) From d0762eb20dfba437376c9a8879524cac0ee9ce7e Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Thu, 4 Apr 2024 23:08:46 -0400 Subject: [PATCH 25/37] add responses to transactions --- .../extensions/core/transaction.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 8e30ec872..2f7f81ff3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -5,6 +5,7 @@ import attr from fastapi import APIRouter, Body, FastAPI 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 @@ -63,7 +64,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, @@ -78,6 +88,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, @@ -92,6 +110,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, @@ -104,7 +130,16 @@ 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, @@ -118,6 +153,14 @@ def register_update_collection(self): name="Update Collection", path="/collections", 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, @@ -131,6 +174,14 @@ 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, From 9d9ab57aa6c4701f655d0c96bbb9a1f8a3ca2cea Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 5 Apr 2024 15:01:53 +0200 Subject: [PATCH 26/37] do not wrap response into response_class --- stac_fastapi/api/stac_fastapi/api/app.py | 28 ++++++--------------- stac_fastapi/api/stac_fastapi/api/errors.py | 3 ++- stac_fastapi/api/stac_fastapi/api/routes.py | 13 +++++++--- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 7db477526..94593f487 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -141,9 +141,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): @@ -170,9 +168,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): @@ -197,9 +193,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): @@ -230,7 +224,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 ), ) @@ -262,7 +256,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 ), ) @@ -290,9 +284,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): @@ -319,9 +311,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): @@ -358,9 +348,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/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/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index 0b8c2c128..495d6777b 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -10,20 +10,25 @@ from stac_pydantic.shared import StacBaseModel 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: +def _wrap_response( + resp: Any, + response_class: Optional[Type[Response]] = None, +) -> Response: if isinstance(resp, Response): return resp elif isinstance(resp, StacBaseModel): return resp elif resp is not None: - return response_class(resp) + if response_class: + return response_class(resp) + return resp else: # None is returned as 204 No Content return Response(status_code=HTTP_204_NO_CONTENT) @@ -41,7 +46,7 @@ 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. From 3d946f6a11b1189054bc7a3087c411834817898a Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 5 Apr 2024 11:57:22 -0400 Subject: [PATCH 27/37] fix tests --- stac_fastapi/api/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 96270baf9..019d4c23b 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -45,7 +45,7 @@ def _assert_dependency_applied(api, routes): 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" From c9e6f0defe583405e698a9b1f63fe139be7f023f Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Fri, 5 Apr 2024 12:08:27 -0400 Subject: [PATCH 28/37] update changelog, remove redundant variable --- CHANGES.md | 4 +++- stac_fastapi/types/stac_fastapi/types/core.py | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ef6012a4b..83746efe5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,9 @@ * Update to pydantic v2 and stac_pydantic v3 * Removed internal Search and Operator Types in favor of stac_pydantic Types -* Add switch to choose between TypeDict and StacPydantic response models +* Fix response model validation +* Add Response Model to OpenAPI, even if model validation is turned off +* Use status code 201 for Item/ Collection creation * Add support for Python 3.12 * Replace Black with Ruff Format diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index bfa77772b..e396c3947 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -378,8 +378,7 @@ def landing_page(self, **kwargs) -> stac.LandingPage: ) # Add Collections links - _collections = self.all_collections(request=kwargs["request"]) - collections = _collections + collections = self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: landing_page["links"].append( @@ -573,8 +572,7 @@ async def landing_page(self, **kwargs) -> stac.LandingPage: ) # Add Collections links - _collections = await self.all_collections(request=kwargs["request"]) - collections = _collections + collections = await self.all_collections(request=kwargs["request"]) for collection in collections["collections"]: landing_page["links"].append( From aa1ab5e4780da3b9f61d7ef768aa9c30049fe0bf Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 9 Apr 2024 17:28:00 +0200 Subject: [PATCH 29/37] lint bench --- stac_fastapi/api/tests/benchmarks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 From 8ad9f9795f216b06254bf07fcd5adbcd5f4cf84b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 9 Apr 2024 18:13:35 +0200 Subject: [PATCH 30/37] reorder installs --- .github/workflows/cicd.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index c2789d253..588c5faea 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -93,14 +93,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 From bf01ad9c011ec3488cf1ee7156b3e0be4890239c Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 9 Apr 2024 18:18:50 +0200 Subject: [PATCH 31/37] do not push benchmark if not in stac-utils/stac-fastapi repo --- .github/workflows/cicd.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 588c5faea..5c6eadbb5 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -105,6 +105,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 From 6b0949aeb157055a8801d46293fa36376776fe8b Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Wed, 10 Apr 2024 07:03:18 -0400 Subject: [PATCH 32/37] Add text about response validation to readme. --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 350ce2589..2a26ed3e9 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 [`stack-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 save. Extra validation would only increase latency, in particular for large payloads. + +To turn on response validation, set `ENABLE_RESPONSE_MODELS` to `True`. Either as 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 From 6f1b47855b778e3b25cc109f313f74a861a1ef3d Mon Sep 17 00:00:00 2001 From: Thomas Maschler Date: Wed, 24 Apr 2024 15:12:25 -0400 Subject: [PATCH 33/37] fix warning --- stac_fastapi/api/stac_fastapi/api/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, ) From d46d287f041c12f316a9d23673ba15cfc246998c Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 26 Apr 2024 08:45:19 +0200 Subject: [PATCH 34/37] remove versions --- stac_fastapi/api/setup.py | 3 +-- stac_fastapi/extensions/setup.py | 5 ++--- stac_fastapi/types/setup.py | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 879ad6e32..af596adf0 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -7,7 +7,7 @@ install_requires = [ "brotli_asgi", - "stac-fastapi.types==2.4.9", + "stac-fastapi.types", ] extra_reqs = { @@ -54,5 +54,4 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version="2.4.9", ) diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index 700cf6061..39bc59b3f 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -7,8 +7,8 @@ desc = f.read() install_requires = [ - "stac-fastapi.types==2.4.9", - "stac-fastapi.api==2.4.9", + "stac-fastapi.types", + "stac-fastapi.api", ] extra_reqs = { @@ -50,5 +50,4 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version="2.4.9", ) diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index b00ae08e5..0b9448e39 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -53,5 +53,4 @@ install_requires=install_requires, tests_require=extra_reqs["dev"], extras_require=extra_reqs, - version="2.4.9", ) From 641614a160bf36ae283c75fe05a466fe071662cf Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 26 Apr 2024 08:51:19 +0200 Subject: [PATCH 35/37] fix --- stac_fastapi/types/stac_fastapi/types/core.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index b591da90d..d0dc029f0 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -313,12 +313,6 @@ def _landing_page( "href": urljoin(base_url, "search"), "method": "POST", }, - { - "rel": Relations.service_desc.value, - "type": MimeTypes.geojson.value, - "title": "Service Description", - "href": api_settings.openapi_url, - }, ], stac_extensions=extension_schemas, ) From 3b50a6d7e8451e131727ddde26e2e4db9fbb5e2d Mon Sep 17 00:00:00 2001 From: Jonathan Healy Date: Fri, 26 Apr 2024 14:53:00 +0800 Subject: [PATCH 36/37] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 869b114b8..02c155993 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ Backends are hosted in their own repositories: A common question when using this package is how request and response types are validated? -This package uses [`stack-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 save. Extra validation would only increase latency, in particular for large payloads. +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 environment variable or directly in the `ApiSettings`. +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. From 37594ecac0f4a08096e0519998a9fb07505a3127 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 26 Apr 2024 09:09:58 +0200 Subject: [PATCH 37/37] update changelog --- CHANGES.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b357b1aac..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,18 +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` - - ### Changed