diff --git a/CHANGES.md b/CHANGES.md index 2355d3534..984592fce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ * Add hook to allow adding dependencies to routes. ([#295](https://github.com/stac-utils/stac-fastapi/pull/295)) * Ability to POST an ItemCollection to the collections/{collectionId}/items route. ([#367](https://github.com/stac-utils/stac-fastapi/pull/367)) * Add STAC API - Collections conformance class. ([383](https://github.com/stac-utils/stac-fastapi/pull/383)) +* Add support for children and browsable STAC apis ([336](https://github.com/stac-utils/stac-fastapi/pull/336)) ### Changed @@ -17,6 +18,7 @@ * Bulk Transactions object Items iterator now returns the Item objects rather than the string IDs of the Item objects ([#355](https://github.com/stac-utils/stac-fastapi/issues/355)) * docker-compose now runs uvicorn with hot-reloading enabled +* Organize clients to avoid extremely long source files ([336](https://github.com/stac-utils/stac-fastapi/pull/336)) ### Removed diff --git a/Makefile b/Makefile index fe2b6fe32..1d193fd88 100644 --- a/Makefile +++ b/Makefile @@ -43,9 +43,14 @@ test-sqlalchemy: run-joplin-sqlalchemy $(run_sqlalchemy) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/sqlalchemy/tests/ && pytest -vvv' .PHONY: test-pgstac -test-pgstac: +test-pgstac: run-joplin-sqlalchemy $(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/pgstac/tests/ && pytest -vvv' + +.PHONY: test-api +test-api: + $(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/api/tests/ && pytest -vvv' + .PHONY: run-database run-database: docker-compose run --rm database @@ -59,7 +64,7 @@ run-joplin-pgstac: docker-compose run --rm loadjoplin-pgstac .PHONY: test -test: test-sqlalchemy test-pgstac +test: test-sqlalchemy test-pgstac test-api .PHONY: pybase-install pybase-install: diff --git a/docker-compose.yml b/docker-compose.yml index 996bb6593..1dbab7b89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - POSTGRES_HOST_WRITER=database - POSTGRES_PORT=5432 - WEB_CONCURRENCY=10 + - BROWSEABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json ports: - "8081:8081" volumes: @@ -50,6 +51,7 @@ services: - GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR - DB_MIN_CONN_SIZE=1 - DB_MAX_CONN_SIZE=1 + - BROWSEABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json ports: - "8082:8082" volumes: diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index a9f8a5542..4f32a15a8 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -7,7 +7,7 @@ from fastapi.openapi.utils import get_openapi from fastapi.params import Depends from pydantic import BaseModel -from stac_pydantic import Collection, Item, ItemCollection +from stac_pydantic import Catalog, Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage from stac_pydantic.api.collections import Collections from stac_pydantic.version import STAC_VERSION @@ -16,6 +16,7 @@ from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers from stac_fastapi.api.models import ( APIRequest, + CatalogUri, CollectionUri, EmptyRequest, GeoJSONResponse, @@ -33,10 +34,12 @@ # TODO: make this module not depend on `stac_fastapi.extensions` from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension +from stac_fastapi.types.clients.async_core import AsyncBaseCoreClient +from stac_fastapi.types.clients.sync_core import BaseCoreClient from stac_fastapi.types.config import ApiSettings, Settings -from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest +from stac_fastapi.types.stac import Children @attr.s @@ -83,9 +86,15 @@ class StacApi: api_version: str = attr.ib(default="0.1") stac_version: str = attr.ib(default=STAC_VERSION) description: str = attr.ib(default="stac-fastapi") + search_get_request_base_model: Type[BaseSearchGetRequest] = attr.ib( + default=BaseSearchGetRequest + ) search_get_request_model: Type[BaseSearchGetRequest] = attr.ib( default=BaseSearchGetRequest ) + search_post_request_base_model: Type[BaseSearchPostRequest] = attr.ib( + default=BaseSearchPostRequest + ) search_post_request_model: Type[BaseSearchPostRequest] = attr.ib( default=BaseSearchPostRequest ) @@ -266,6 +275,44 @@ def register_get_collection(self): ), ) + def register_get_root_children(self): + """Register get collection children endpoint (GET /children). + + Returns: + None + """ + self.router.add_api_route( + name="Get Root Children", + path="/children", + response_model=Children if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.get_root_children, EmptyRequest, self.response_class + ), + ) + + def register_get_catalog_children(self): + """Register get collection children endpoint (GET /collection/{collection_id}/children). + + Returns: + None + """ + self.router.add_api_route( + name="Get Root Children", + path="/catalogs/{catalog_path:path}/children", + response_model=Children if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.get_catalog_children, CatalogUri, self.response_class + ), + ) + def register_get_item_collection(self): """Register get item collection endpoint (GET /collection/{collection_id}/items). @@ -293,6 +340,119 @@ def register_get_item_collection(self): ), ) + def register_catalog_conformance_classes(self): + """Register catalog conformance class endpoint (GET /catalogs/{catalog_path}/conformance). + + Returns: + None + """ + self.router.add_api_route( + name="Conformance Classes", + path="/catalogs/{catalog_path:path}/conformance", + 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, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.conformance, EmptyRequest, self.response_class + ), + ) + + def register_post_catalog_search(self): + """Register search endpoint (POST /search). + + Returns: + None + """ + fields_ext = self.get_extension(FieldsExtension) + self.router.add_api_route( + name="Search", + path="/catalogs/{catalog_path:path}/search", + 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, + methods=["POST"], + endpoint=self._create_endpoint( + self.client.post_catalog_search, + self.search_post_request_model, + GeoJSONResponse, + ), + ) + + def register_get_catalog_search(self): + """Register catalog search endpoint (GET /catalogs/{catalog_path}/search). + + Returns: + None + """ + fields_ext = self.get_extension(FieldsExtension) + request_model = create_request_model( + "GetSearchWithCatalogUri", + base_model=self.search_get_request_base_model, + extensions=self.extensions, + mixins=[CatalogUri], + ) + self.router.add_api_route( + name="Catalog Search", + path="/catalogs/{catalog_path:path}/search", + 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, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.get_catalog_search, request_model, GeoJSONResponse + ), + ) + + def register_get_catalog_collections(self): + """Register get collections endpoint (GET /collections). + + Returns: + None + """ + self.router.add_api_route( + name="Get Collections", + path="/catalogs/{catalog_path:path}/collections", + 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, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.get_catalog_collections, CatalogUri, self.response_class + ), + ) + + def register_get_catalog(self): + """Register get collection endpoint (GET /catalog/{catalog_path}). + + Returns: + None + """ + self.router.add_api_route( + name="Get Catalog", + path="/catalogs/{catalog_path:path}", + response_model=Catalog if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self._create_endpoint( + self.client.get_catalog, CatalogUri, self.response_class + ), + ) + def register_core(self): """Register core STAC endpoints. @@ -319,6 +479,16 @@ def register_core(self): self.register_get_collection() self.register_get_item_collection() + # Browseable endpoints + self.register_catalog_conformance_classes() + self.register_post_catalog_search() + self.register_get_catalog_search() + self.register_get_catalog_collections() + if self.settings.browseable_hierarchy_definition is not None: + self.register_get_root_children() + self.register_get_catalog_children() + self.register_get_catalog() + def customize_openapi(self) -> Optional[Dict[str, Any]]: """Customize openapi schema.""" if self.app.openapi_schema: diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3e4e01fe3..04f1bc6fc 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 Body, 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[List[Union[BaseModel, APIRequest]]] = None, request_type: Optional[str] = "GET", ) -> Union[Type[BaseModel], APIRequest]: """Create a pydantic model for validating request bodies.""" @@ -74,39 +74,48 @@ def create_request_model( def create_get_request_model( - extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest + extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest, mixins=None ): """Wrap create_request_model to create the GET request model.""" return create_request_model( "SearchGetRequest", base_model=base_model, extensions=extensions, + mixins=mixins, request_type="GET", ) def create_post_request_model( - extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest + extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest, mixins=None ): """Wrap create_request_model to create the POST request model.""" return create_request_model( "SearchPostRequest", base_model=base_model, extensions=extensions, + mixins=mixins, request_type="POST", ) +@attr.s # type:ignore +class CatalogUri(APIRequest): + """Catalog Path.""" + + catalog_path: str = attr.ib(default=Path(..., description="Catalog Path")) + + @attr.s # type:ignore class CollectionUri(APIRequest): - """Delete collection.""" + """Collection URI.""" collection_id: str = attr.ib(default=Path(..., description="Collection ID")) @attr.s class ItemUri(CollectionUri): - """Delete item.""" + """Item URI.""" item_id: str = attr.ib(default=Path(..., description="Item ID")) diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 7d3406568..3282be993 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -3,7 +3,9 @@ from stac_fastapi.api.app import StacApi from stac_fastapi.extensions.core import TokenPaginationExtension, TransactionExtension -from stac_fastapi.types import config, core +from stac_fastapi.types import config +from stac_fastapi.types.clients.sync_core import BaseCoreClient +from stac_fastapi.types.clients.transaction import BaseTransactionsClient class TestRouteDependencies: @@ -76,7 +78,7 @@ def test_add_route_dependencies_after_building_api(self): self._assert_dependency_applied(api, routes) -class DummyCoreClient(core.BaseCoreClient): +class DummyCoreClient(BaseCoreClient): def all_collections(self, *args, **kwargs): ... @@ -95,8 +97,11 @@ def post_search(self, *args, **kwargs): def item_collection(self, *args, **kwargs): ... + def get_root_children(self, **kwargs): + ... + -class DummyTransactionsClient(core.BaseTransactionsClient): +class DummyTransactionsClient(BaseTransactionsClient): """Defines a pattern for implementing the STAC transaction extension.""" def create_item(self, *args, **kwargs): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py index c5342ae33..356555cbb 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -9,7 +9,7 @@ from stac_fastapi.api.models import APIRequest, CollectionUri, EmptyRequest from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint -from stac_fastapi.types.core import AsyncBaseFiltersClient, BaseFiltersClient +from stac_fastapi.types.clients.filter import AsyncBaseFiltersClient, BaseFiltersClient from stac_fastapi.types.extension import ApiExtension from .request import FilterExtensionGetRequest, FilterExtensionPostRequest 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 2e2a80066..73085979f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py @@ -23,7 +23,7 @@ class SortExtension(ApiExtension): POST = SortExtensionPostRequest conformance_classes: List[str] = attr.ib( - factory=lambda: ["https://api.stacspec.org/v1.0.0-rc.1/item-search#sort"] + factory=lambda: ["https://api.stacspec.org/v1.0.0-rc.1/item-search/#sort"] ) schema_href: Optional[str] = attr.ib(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 476301fc9..89fcb6e03 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -10,8 +10,11 @@ from stac_fastapi.api.models import APIRequest, CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.clients.transaction import ( + AsyncBaseTransactionsClient, + BaseTransactionsClient, +) from stac_fastapi.types.config import ApiSettings -from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension @@ -38,7 +41,7 @@ class TransactionExtension(ApiExtension): settings: ApiSettings = attr.ib() conformance_classes: List[str] = attr.ib( factory=lambda: [ - "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features/extensions/transaction", + "https://api.stacspec.org/v1.0.0-rc.1/ogcapi-features/extensions/transaction/", "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/simpletx", ] ) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py index b1dd94c57..94108789b 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py @@ -16,6 +16,7 @@ from stac_fastapi.pgstac.extensions import QueryExtension from stac_fastapi.pgstac.transactions import TransactionsClient from stac_fastapi.pgstac.types.search import PgstacSearch +from stac_fastapi.types.hierarchy import parse_hierarchy_file settings = Settings() extensions = [ @@ -30,15 +31,19 @@ TokenPaginationExtension(), ContextExtension(), ] +hierarchy_definition = parse_hierarchy_file(settings.browseable_hierarchy_definition) post_request_model = create_post_request_model(extensions, base_model=PgstacSearch) api = StacApi( settings=settings, extensions=extensions, - client=CoreCrudClient(post_request_model=post_request_model), + client=CoreCrudClient( + post_request_model=post_request_model, hierarchy_definition=hierarchy_definition + ), response_class=ORJSONResponse, search_get_request_model=create_get_request_model(extensions), + search_post_request_base_model=PgstacSearch, search_post_request_model=post_request_model, ) app = api.app diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index 4de102255..47bd2cc4e 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -8,19 +8,25 @@ import orjson from asyncpg.exceptions import InvalidDatetimeFormatError from buildpg import render -from fastapi import HTTPException +from fastapi import HTTPException, Request from pydantic import ValidationError from pygeofilter.backends.cql2_json import to_cql2 from pygeofilter.parsers.cql2_text import parse as parse_cql2_text from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes -from starlette.requests import Request from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks, PagingLinks from stac_fastapi.pgstac.types.search import PgstacSearch -from stac_fastapi.types.core import AsyncBaseCoreClient +from stac_fastapi.types.clients.async_core import AsyncBaseCoreClient from stac_fastapi.types.errors import InvalidQueryParameter, NotFoundError -from stac_fastapi.types.stac import Collection, Collections, Item, ItemCollection +from stac_fastapi.types.hierarchy import browseable_catalog_page +from stac_fastapi.types.stac import ( + Children, + Collection, + Collections, + Item, + ItemCollection, +) NumType = Union[float, int] @@ -103,6 +109,53 @@ async def get_collection(self, collection_id: str, **kwargs) -> Collection: return Collection(**collection) + async def get_root_children(self, **kwargs) -> Children: + """Get children of root catalog. + + Called with `GET /children`. + + Returns: + Children. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + catalog_children = [ + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + collection_children = await self.all_collections(**kwargs) + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "children"), + }, + ] + return Children( + children=catalog_children + collection_children["collections"], links=links + ) + async def _search_base( self, search_request: PgstacSearch, **kwargs: Any ) -> ItemCollection: diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py index 854d02847..6308ad1c5 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/transactions.py @@ -8,7 +8,7 @@ from stac_fastapi.pgstac.db import dbfunc from stac_fastapi.types import stac as stac_types -from stac_fastapi.types.core import AsyncBaseTransactionsClient +from stac_fastapi.types.clients.transaction import AsyncBaseTransactionsClient logger = logging.getLogger("uvicorn") logger.setLevel(logging.INFO) diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py index 29a0894ac..9aa6d50e8 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py @@ -17,6 +17,7 @@ BulkTransactionsClient, TransactionsClient, ) +from stac_fastapi.types.hierarchy import parse_hierarchy_file settings = SqlalchemySettings() session = Session.create_from_settings(settings) @@ -29,6 +30,7 @@ TokenPaginationExtension(), ContextExtension(), ] +hierarchy_definition = parse_hierarchy_file(settings.browseable_hierarchy_definition) post_request_model = create_post_request_model(extensions) @@ -36,7 +38,10 @@ settings=settings, extensions=extensions, client=CoreCrudClient( - session=session, extensions=extensions, post_request_model=post_request_model + session=session, + extensions=extensions, + post_request_model=post_request_model, + hierarchy_definition=hierarchy_definition, ), search_get_request_model=create_get_request_model(extensions), search_post_request_model=post_request_model, diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index cd1ca9eea..1b1184ad5 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -25,8 +25,8 @@ from stac_fastapi.sqlalchemy.models import database from stac_fastapi.sqlalchemy.session import Session from stac_fastapi.sqlalchemy.tokens import PaginationTokenClient +from stac_fastapi.types.clients.sync_core import BaseCoreClient from stac_fastapi.types.config import Settings -from stac_fastapi.types.core import BaseCoreClient from stac_fastapi.types.errors import NotFoundError from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import Collection, Collections, Item, ItemCollection diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py index 1ae1d6f2e..04682caef 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/transactions.py @@ -14,7 +14,7 @@ from stac_fastapi.sqlalchemy.models import database from stac_fastapi.sqlalchemy.session import Session from stac_fastapi.types import stac as stac_types -from stac_fastapi.types.core import BaseTransactionsClient +from stac_fastapi.types.clients.transaction import BaseTransactionsClient from stac_fastapi.types.errors import NotFoundError logger = logging.getLogger(__name__) diff --git a/stac_fastapi/sqlalchemy/tests/resources/test_item.py b/stac_fastapi/sqlalchemy/tests/resources/test_item.py index f75a802bf..fb8dd3748 100644 --- a/stac_fastapi/sqlalchemy/tests/resources/test_item.py +++ b/stac_fastapi/sqlalchemy/tests/resources/test_item.py @@ -13,7 +13,7 @@ from shapely.geometry import Polygon from stac_fastapi.sqlalchemy.core import CoreCrudClient -from stac_fastapi.types.core import LandingPageMixin +from stac_fastapi.types.clients.landing import LandingPageMixin from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime diff --git a/stac_fastapi/testdata/joplin/hierarchy.json b/stac_fastapi/testdata/joplin/hierarchy.json new file mode 100644 index 000000000..8f77477b0 --- /dev/null +++ b/stac_fastapi/testdata/joplin/hierarchy.json @@ -0,0 +1,35 @@ +{ + "children": [{ + "catalog_id": "joplin", + "title": "Joplin Item Catalog", + "description": "All joplin items", + "children": [{ + "catalog_id": "joplin-subset", + "title": "Joplin Item Subset Catalog", + "description": "A subset of joplin items", + "children": [], + "items": [ + ["joplin", "fe916452-ba6f-4631-9154-c249924a122d"], + ["joplin", "f7f164c9-cfdf-436d-a3f0-69864c38ba2a"], + ["joplin", "f734401c-2df0-4694-a353-cdd3ea760cdc"] + ] + }, { + "collection_id": "joplin", + "children": [], + "items": [] + }], + "items": [ + ["joplin", "fe916452-ba6f-4631-9154-c249924a122d"], + ["joplin", "f7f164c9-cfdf-436d-a3f0-69864c38ba2a"], + ["joplin", "f734401c-2df0-4694-a353-cdd3ea760cdc"], + ["joplin", "f490b7af-0019-45e2-854b-3854d07fd063"], + ["joplin", "f2cca2a3-288b-4518-8a3e-a4492bb60b08"], + ["joplin", "ea0fddf4-56f9-4a16-8a0b-f6b0b123b7cf"], + ["joplin", "e0a02e4e-aa0c-412e-8f63-6f5344f829df"], + ["joplin", "da6ef938-c58f-4bab-9d4e-89f6ae667da2"], + ["joplin", "d8461d8c-3d2b-4e4e-a931-7ae61ca06dbf"], + ["joplin", "d4eccfa2-7d77-4624-9e2a-3f59102285bb"] + ] + }], + "items": [] +} \ No newline at end of file diff --git a/stac_fastapi/types/stac_fastapi/types/clients/__init__.py b/stac_fastapi/types/stac_fastapi/types/clients/__init__.py new file mode 100644 index 000000000..718cec54e --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/__init__.py @@ -0,0 +1 @@ +"""clients submodule.""" diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py new file mode 100644 index 000000000..5b5d84718 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -0,0 +1,590 @@ +"""Base clients.""" +import abc +import asyncio +from datetime import datetime +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urljoin + +import attr +from fastapi import HTTPException, Request +from stac_pydantic.api import Search +from stac_pydantic.links import Relations +from stac_pydantic.shared import MimeTypes + +from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.clients.landing import LandingPageMixin +from stac_fastapi.types.conformance import ( + BASE_CONFORMANCE_CLASSES, + BROWSEABLE_CONFORMANCE_CLASS, + CHILDREN_CONFORMANCE_CLASS, +) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.hierarchy import ( + BrowseableNode, + browseable_catalog_link, + browseable_catalog_page, + browseable_collection_link, + browseable_item_link, + find_catalog, +) +from stac_fastapi.types.search import BaseSearchPostRequest +from stac_fastapi.types.stac import Conformance + +NumType = Union[float, int] +StacType = Dict[str, Any] + + +@attr.s # type:ignore +class AsyncBaseCoreClient(LandingPageMixin, abc.ABC): + """Defines a pattern for implementing STAC api core endpoints. + + Attributes: + extensions: list of registered api extensions. + """ + + base_conformance_classes: List[str] = attr.ib( + factory=lambda: BASE_CONFORMANCE_CLASSES + ) + extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + post_request_model = attr.ib(default=BaseSearchPostRequest) + hierarchy_definition: Optional[BrowseableNode] = attr.ib(default=None) + + def conformance_classes(self) -> List[str]: + """Generate conformance classes by adding extension conformance to base conformance classes.""" + conformance_classes = self.base_conformance_classes.copy() + + # TODO: replace hierarchy_definition with a flag + if self.hierarchy_definition is not None: + conformance_classes.append(BROWSEABLE_CONFORMANCE_CLASS) + conformance_classes.append(CHILDREN_CONFORMANCE_CLASS) + + for extension in self.extensions: + extension_classes = getattr(extension, "conformance_classes", []) + conformance_classes.extend(extension_classes) + + return list(set(conformance_classes)) + + 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: + """Landing page. + + Called with `GET /`. + + Returns: + API landing page, serving as an entry point to the API. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + landing_page = self._landing_page( + base_url=base_url, + conformance_classes=self.conformance_classes(), + extension_schemas=extension_schemas, + ) + + # Add Collections links + collections = await self.all_collections(request=kwargs["request"]) + for collection in collections["collections"]: + landing_page["links"].append( + { + "rel": Relations.child.value, + "type": MimeTypes.json.value, + "title": collection.get("title") or collection.get("id"), + "href": urljoin(base_url, f"collections/{collection['id']}"), + } + ) + + # Add links for children and browseable conformance + if self.hierarchy_definition is not None: + # Children + landing_page["links"].append( + { + "rel": Relations.children, + "type": MimeTypes.json.value, + "title": "Child collections and catalogs", + "href": urljoin(base_url, "children"), + } + ) + + for child in self.hierarchy_definition["children"]: + if "collection_id" in child: + landing_page["links"].append( + browseable_collection_link(child, base_url) + ) + if "catalog_id" in child: + landing_page["links"].append( + browseable_catalog_link(child, base_url, child["catalog_id"]) + ) + for item in self.hierarchy_definition["items"]: + landing_page["links"].append(browseable_item_link(item, base_url)) + + # Add OpenAPI URL + landing_page["links"].append( + { + "rel": "service-desc", + "type": "application/vnd.oai.openapi+json;version=3.0", + "title": "OpenAPI service description", + "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), + } + ) + + # Add human readable service-doc + landing_page["links"].append( + { + "rel": "service-doc", + "type": "text/html", + "title": "OpenAPI service documentation", + "href": urljoin(base_url, request.app.docs_url.lstrip("/")), + } + ) + + return landing_page + + async def conformance(self, **kwargs) -> stac_types.Conformance: + """Conformance classes. + + Called with `GET /conformance`. + + Returns: + Conformance classes which the server conforms to. + """ + return Conformance(conformsTo=self.conformance_classes()) + + @abc.abstractmethod + async def post_search( + self, search_request: Search, **kwargs + ) -> stac_types.ItemCollection: + """Cross catalog search (POST). + + Called with `POST /search`. + + Args: + search_request: search request parameters. + + Returns: + ItemCollection containing items which match the search criteria. + """ + ... + + @abc.abstractmethod + async def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + query: Optional[str] = None, + token: Optional[str] = None, + fields: Optional[List[str]] = None, + sortby: Optional[str] = None, + **kwargs, + ) -> stac_types.ItemCollection: + """Cross catalog search (GET). + + Called with `GET /search`. + + Returns: + ItemCollection containing items which match the search criteria. + """ + ... + + @abc.abstractmethod + async def get_item( + self, item_id: str, collection_id: str, **kwargs + ) -> stac_types.Item: + """Get item by id. + + Called with `GET /collections/{collection_id}/items/{item_id}`. + + Args: + item_id: Id of the item. + collection_id: Id of the collection. + + Returns: + Item. + """ + ... + + @abc.abstractmethod + async def all_collections(self, **kwargs) -> stac_types.Collections: + """Get all available collections. + + Called with `GET /collections`. + + Returns: + A list of collections. + """ + ... + + @abc.abstractmethod + async def get_collection( + self, collection_id: str, **kwargs + ) -> stac_types.Collection: + """Get collection by id. + + Called with `GET /collections/{collection_id}`. + + Args: + collection_id: Id of the collection. + + Returns: + Collection. + """ + ... + + async def get_root_children(self, **kwargs) -> stac_types.Children: + """Get children at root. + + Called with `GET /children`. + + Returns: + Children. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + if self.hierarchy_definition: + catalog_children = [ + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + else: + catalog_children = [] + collection_children = await self.all_collections(**kwargs) + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "children"), + }, + ] + return stac_types.Children( + children=catalog_children + collection_children["collections"], links=links + ) + + async def get_catalog_children( + self, catalog_path: str, **kwargs + ) -> stac_types.Children: + """Get children by catalog path. + + Called with `GET /catalogs/{catalog_path}/children`. + + Args: + catalog_path: Path through hierarchy to catalog. + + Returns: + Children. + """ + hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(hierarchy, split_path) + request: Request = kwargs["request"] + base_url = str(request.base_url) + + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + catalog_children = [ + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + for child in selected_catalog["children"] + if "catalog_id" in child + ] + child_collection_ids = [ + child["collection_id"] + for child in selected_catalog["children"] + if "collection_id" in child + ] + all_collections = await self.all_collections(**kwargs) + collection_children = [ + coll + for coll in all_collections["collections"] + if coll["id"] in child_collection_ids + ] + + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "children"), + }, + ] + return stac_types.Children( + children=catalog_children + collection_children, links=links + ) + + async def post_catalog_search( + self, search_request: Search, **kwargs + ) -> stac_types.ItemCollection: + """Catalog refined search (POST). + + Called with `POST /search`. + + Args: + search_request: search request parameters. + + Returns: + ItemCollection containing items which match the search criteria. + """ + # Pydantic is fine for specifying body parameters but proves difficult to + # use for Path parameters. Here, the starlette request is inspected instead + request_path = kwargs["request"]["path"] + split_path = request_path.split("/")[2:-1] + remaining_hierarchy = self.hierarchy_definition.copy() + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + child_collections = [ + node["collection_id"] + for node in selected_catalog["children"] + if "collection_id" in node + ] + search_request.collections = child_collections + return await self.post_search(search_request, **kwargs) + + async def get_catalog_search( + self, + catalog_path: str, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = 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, + **kwargs, + ) -> stac_types.ItemCollection: + """Cross catalog search (GET). + + Called with `GET /search`. + + Returns: + ItemCollection containing items which match the search criteria. + """ + hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + # What should we do with the collections provided by the user? + # Xor/toggle search collections as gathered via the hierarchy? I'm guessing people would + # be surprised by just about any behavior we pick. Maybe just ignore provided collections? + child_collections = [ + node["collection_id"] + for node in selected_catalog["children"] + if "collection_id" in node + ] + print("CHILD COLLECTION IN SEARCH", child_collections) + return await self.get_search( + child_collections, + ids, + bbox, + datetime, + limit, + query, + token, + fields, + sortby, + **kwargs, + ) + + async def get_catalog_collections( + self, catalog_path: str, **kwargs + ) -> stac_types.Collections: + """Get all subcollections of a catalog. + + Called with `GET /catalogs/{catalog_path}/collections`. + + Args: + catalog_path: The full path of the catalog in the browseable hierarchy. + + Returns: + Collections. + """ + remaining_hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + child_collections_io = [ + self.get_collection(node["collection_id"], **kwargs) + for node in selected_catalog["children"] + if "collection_id" in node + ] + child_collections = await asyncio.gather(*child_collections_io) + + base_url = str(kwargs["request"].base_url).strip("/") + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": "/".join([base_url, "catalogs", catalog_path]), + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": "/".join([base_url, "catalogs", catalog_path, "collections"]), + }, + ] + return stac_types.Collections(collections=child_collections or [], links=links) + + async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: + """Get collection by id. + + Called with `GET /catalogs/{catalog_path}`. + + Args: + catalog_path: The full path of the catalog in the browseable hierarchy. + + Returns: + Catalog. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + split_path = catalog_path.split("/") + remaining_hierarchy = self.hierarchy_definition.copy() + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + + return browseable_catalog_page( + selected_catalog, + base_url, + catalog_path, + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + + @abc.abstractmethod + async def item_collection( + self, collection_id: str, limit: int = 10, token: str = None, **kwargs + ) -> stac_types.ItemCollection: + """Get all items from a specific collection. + + Called with `GET /collections/{collection_id}/items` + + Args: + collection_id: id of the collection. + limit: number of items to return. + token: pagination token. + + Returns: + An ItemCollection. + """ + ... + + +@attr.s +class AsyncBaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + async def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all + queryables over all collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + + https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } + + +@attr.s +class BaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all + queryables over all collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + + https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } diff --git a/stac_fastapi/types/stac_fastapi/types/clients/filter.py b/stac_fastapi/types/stac_fastapi/types/clients/filter.py new file mode 100644 index 000000000..b627be374 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/filter.py @@ -0,0 +1,59 @@ +"""Base clients.""" +import abc +from typing import Any, Dict, Optional + +import attr + + +@attr.s +class AsyncBaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + async def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all + queryables over all collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + + https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } + + +@attr.s +class BaseFiltersClient(abc.ABC): + """Defines a pattern for implementing the STAC filter extension.""" + + def get_queryables( + self, collection_id: Optional[str] = None, **kwargs + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id. + + If collection_id is None, returns the intersection of all + queryables over all collections. + + This base implementation returns a blank queryable schema. This is not allowed + under OGC CQL but it is allowed by the STAC API Filter Extension + + https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables + """ + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.org/queryables", + "type": "object", + "title": "Queryables for Example STAC API", + "description": "Queryable names for the example STAC API Item Search filter.", + "properties": {}, + } diff --git a/stac_fastapi/types/stac_fastapi/types/clients/landing.py b/stac_fastapi/types/stac_fastapi/types/clients/landing.py new file mode 100644 index 000000000..781e0defd --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/landing.py @@ -0,0 +1,75 @@ +"""Base clients.""" +import abc +from typing import List +from urllib.parse import urljoin + +import attr +from stac_pydantic.links import Relations +from stac_pydantic.shared import MimeTypes +from stac_pydantic.version import STAC_VERSION + +from stac_fastapi.types import stac as stac_types + + +@attr.s +class LandingPageMixin(abc.ABC): + """Create a STAC landing page (GET /).""" + + stac_version: str = attr.ib(default=STAC_VERSION) + landing_page_id: str = attr.ib(default="stac-fastapi") + title: str = attr.ib(default="stac-fastapi") + description: str = attr.ib(default="stac-fastapi") + + def _landing_page( + self, + base_url: str, + conformance_classes: List[str], + extension_schemas: List[str], + ) -> stac_types.LandingPage: + landing_page = stac_types.LandingPage( + type="Catalog", + id=self.landing_page_id, + title=self.title, + description=self.description, + stac_version=self.stac_version, + conformsTo=conformance_classes, + links=[ + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": "data", + "type": MimeTypes.json, + "href": urljoin(base_url, "collections"), + }, + { + "rel": Relations.conformance.value, + "type": MimeTypes.json, + "title": "STAC/WFS3 conformance classes implemented by this server", + "href": urljoin(base_url, "conformance"), + }, + { + "rel": Relations.search.value, + "type": MimeTypes.geojson, + "title": "STAC search", + "href": urljoin(base_url, "search"), + "method": "GET", + }, + { + "rel": Relations.search.value, + "type": MimeTypes.geojson, + "title": "STAC search", + "href": urljoin(base_url, "search"), + "method": "POST", + }, + ], + stac_extensions=extension_schemas, + ) + return landing_page diff --git a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py new file mode 100644 index 000000000..cb8c41bd1 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -0,0 +1,539 @@ +"""Base clients.""" +import abc +from datetime import datetime +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urljoin + +import attr +from fastapi import HTTPException, Request +from stac_pydantic.api import Search +from stac_pydantic.links import Relations +from stac_pydantic.shared import MimeTypes + +from stac_fastapi.types import stac as stac_types +from stac_fastapi.types.clients.landing import LandingPageMixin +from stac_fastapi.types.conformance import ( + BASE_CONFORMANCE_CLASSES, + BROWSEABLE_CONFORMANCE_CLASS, + CHILDREN_CONFORMANCE_CLASS, +) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.hierarchy import ( + BrowseableNode, + browseable_catalog_link, + browseable_catalog_page, + browseable_collection_link, + browseable_item_link, + find_catalog, +) +from stac_fastapi.types.search import BaseSearchPostRequest +from stac_fastapi.types.stac import Conformance + +NumType = Union[float, int] +StacType = Dict[str, Any] + + +@attr.s # type:ignore +class BaseCoreClient(LandingPageMixin, abc.ABC): + """Defines a pattern for implementing STAC api core endpoints. + + Attributes: + extensions: list of registered api extensions. + """ + + base_conformance_classes: List[str] = attr.ib( + factory=lambda: BASE_CONFORMANCE_CLASSES + ) + extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + post_request_model = attr.ib(default=BaseSearchPostRequest) + hierarchy_definition: Optional[BrowseableNode] = attr.ib(default=None) + + def conformance_classes(self) -> List[str]: + """Generate conformance classes by adding extension conformance to base conformance classes.""" + conformance_classes = self.base_conformance_classes.copy() + if self.hierarchy_definition: + conformance_classes.append(BROWSEABLE_CONFORMANCE_CLASS) + conformance_classes.append(CHILDREN_CONFORMANCE_CLASS) + + for extension in self.extensions: + extension_classes = getattr(extension, "conformance_classes", []) + conformance_classes.extend(extension_classes) + + return list(set(conformance_classes)) + + 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]) + + def list_conformance_classes(self): + """Return a list of conformance classes, including implemented extensions.""" + base_conformance = BASE_CONFORMANCE_CLASSES + + for extension in self.extensions: + extension_classes = getattr(extension, "conformance_classes", []) + base_conformance.extend(extension_classes) + + return base_conformance + + def landing_page(self, **kwargs) -> stac_types.LandingPage: + """Landing page. + + Called with `GET /`. + + Returns: + API landing page, serving as an entry point to the API. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + request: Request = kwargs["request"] + base_url = str(request.base_url) + landing_page = self._landing_page( + base_url=base_url, + conformance_classes=self.conformance_classes(), + extension_schemas=extension_schemas, + ) + + # Add Collections links + collections = self.all_collections(request=kwargs["request"]) + for collection in collections["collections"]: + landing_page["links"].append( + { + "rel": Relations.child.value, + "type": MimeTypes.json.value, + "title": collection.get("title") or collection.get("id"), + "href": urljoin(base_url, f"collections/{collection['id']}"), + } + ) + + # Add links for browseable and children conformance + if self.hierarchy_definition is not None: + # Children + landing_page["links"].append( + { + "rel": Relations.children, + "type": MimeTypes.json.value, + "title": "Child collections and catalogs", + "href": urljoin(base_url, "children"), + } + ) + + # Browseable + for child in self.hierarchy_definition["children"]: + if "collection_id" in child: + landing_page["links"].append( + browseable_collection_link( + child, urljoin(base_url, "collections") + ) + ) + if "catalog_id" in child: + landing_page["links"].append( + browseable_catalog_link(child, base_url, child["catalog_id"]) + ) + for item in self.hierarchy_definition["items"]: + landing_page["links"].append(browseable_item_link(item, base_url)) + + # Add OpenAPI URL + landing_page["links"].append( + { + "rel": "service-desc", + "type": "application/vnd.oai.openapi+json;version=3.0", + "title": "OpenAPI service description", + "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), + } + ) + + # Add human readable service-doc + landing_page["links"].append( + { + "rel": "service-doc", + "type": "text/html", + "title": "OpenAPI service documentation", + "href": urljoin(base_url, request.app.docs_url.lstrip("/")), + } + ) + + return landing_page + + def conformance(self, **kwargs) -> stac_types.Conformance: + """Conformance classes. + + Called with `GET /conformance`. + + Returns: + Conformance classes which the server conforms to. + """ + return Conformance(conformsTo=self.conformance_classes()) + + @abc.abstractmethod + def post_search( + self, search_request: Search, **kwargs + ) -> stac_types.ItemCollection: + """Cross catalog search (POST). + + Called with `POST /search`. + + Args: + search_request: search request parameters. + + Returns: + ItemCollection containing items which match the search criteria. + """ + ... + + @abc.abstractmethod + def get_search( + self, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + query: Optional[str] = None, + token: Optional[str] = None, + fields: Optional[List[str]] = None, + sortby: Optional[str] = None, + **kwargs, + ) -> stac_types.ItemCollection: + """Cross catalog search (GET). + + Called with `GET /search`. + + Returns: + ItemCollection containing items which match the search criteria. + """ + ... + + @abc.abstractmethod + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + """Get item by id. + + Called with `GET /collections/{collection_id}/items/{item_id}`. + + Args: + item_id: Id of the item. + collection_id: Id of the collection. + + Returns: + Item. + """ + ... + + @abc.abstractmethod + def all_collections(self, **kwargs) -> stac_types.Collections: + """Get all available collections. + + Called with `GET /collections`. + + Returns: + A list of collections. + """ + ... + + @abc.abstractmethod + def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + """Get collection by id. + + Called with `GET /collections/{collection_id}`. + + Args: + collection_id: Id of the collection. + + Returns: + Collection. + """ + ... + + def get_root_children(self, **kwargs) -> stac_types.Children: + """Get children at root. + + Called with `GET /children`. + + Returns: + Children. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + if self.hierarchy_definition: + catalog_children = [ + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + else: + catalog_children = [] + collection_children = self.all_collections(**kwargs) + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "children"), + }, + ] + return stac_types.Children( + children=catalog_children + collection_children["collections"], links=links + ) + + def get_catalog_children(self, catalog_path: str, **kwargs) -> stac_types.Children: + """Get children by catalog path. + + Called with `GET /catalogs/{catalog_path}/children`. + + Args: + catalog_path: Path through hierarchy to catalog. + + Returns: + Children. + """ + hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(hierarchy, split_path) + request: Request = kwargs["request"] + base_url = str(request.base_url) + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + catalog_children = [ + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + + child_collection_ids = [ + child["collection_id"] + for child in selected_catalog["children"] + if "collection_id" in child + ] + all_collections = self.all_collections(**kwargs) + collection_children = [ + coll + for coll in all_collections["collections"] + if coll["id"] in child_collection_ids + ] + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "children"), + }, + ] + return stac_types.Children( + children=catalog_children + collection_children, links=links + ) + + def post_catalog_search( + self, search_request: Search, **kwargs + ) -> stac_types.ItemCollection: + """Catalog refined search (POST). + + Called with `POST /search`. + + Args: + search_request: search request parameters. + + Returns: + ItemCollection containing items which match the search criteria. + """ + # Pydantic is fine for specifying body parameters but proves difficult to + # use for Path parameters. Here, the starlette request is inspected instead + request_path = kwargs["request"]["path"] + split_path = request_path.split("/")[2:-1] + remaining_hierarchy = self.hierarchy_definition.copy() + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + child_collections = [ + node["collection_id"] + for node in selected_catalog["children"] + if "collection_id" in node + ] + search_request.collections = child_collections + return self.post_search(search_request, **kwargs) + + def get_catalog_search( + self, + catalog_path: str, + collections: Optional[List[str]] = None, + ids: Optional[List[str]] = None, + bbox: Optional[List[NumType]] = 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, + **kwargs, + ) -> stac_types.ItemCollection: + """Catalog refined search (GET). + + Called with `GET /search`. + + Returns: + ItemCollection containing items which match the search criteria. + """ + remaining_hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + # What should we do with the collections provided by the user? + # Xor/toggle search collections as gathered via the hierarchy? I'm guessing people would + # be surprised by just about any behavior we pick. Maybe just ignore provided collections? + child_collections = [ + node["collection_id"] + for node in selected_catalog["children"] + if "collection_id" in node + ] + return self.get_search( + child_collections, + ids, + bbox, + datetime, + limit, + query, + token, + fields, + sortby, + **kwargs, + ) + + def get_catalog_collections( + self, catalog_path: str, **kwargs + ) -> stac_types.Collections: + """Get all subcollections of a catalog. + + Called with `GET /catalogs/{catalog_path}/collections`. + + Args: + catalog_path: The full path of the catalog in the browseable hierarchy. + + Returns: + Collections. + """ + remaining_hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + child_collections = [ + self.get_collection(node["collection_id"], **kwargs) + for node in selected_catalog["children"] + if "collection_id" in node + ] + + base_url = str(kwargs["request"].base_url).strip("/") + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": "/".join([base_url, "catalogs", catalog_path]), + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": "/".join([base_url, "catalogs", catalog_path, "collections"]), + }, + ] + return stac_types.Collections(collections=child_collections or [], links=links) + + def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: + """Get collection by id. + + Called with `GET /catalogs/{catalog_path}`. + + Args: + catalog_path: The full path of the catalog in the browseable hierarchy. + + Returns: + Catalog. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + split_path = catalog_path.split("/") + remaining_hierarchy = self.hierarchy_definition.copy() + selected_catalog = find_catalog(remaining_hierarchy, split_path) + if not selected_catalog: + raise HTTPException( + status_code=404, detail=f"Catalog at {'/'.join(split_path)} not found" + ) + + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + + return browseable_catalog_page( + selected_catalog, + base_url, + catalog_path, + self.stac_version, + self.conformance_classes(), + extension_schemas, + ) + + @abc.abstractmethod + def item_collection( + self, collection_id: str, limit: int = 10, token: str = None, **kwargs + ) -> stac_types.ItemCollection: + """Get all items from a specific collection. + + Called with `GET /collections/{collection_id}/items` + + Args: + collection_id: id of the collection. + limit: number of items to return. + token: pagination token. + + Returns: + An ItemCollection. + """ + ... diff --git a/stac_fastapi/types/stac_fastapi/types/clients/transaction.py b/stac_fastapi/types/stac_fastapi/types/clients/transaction.py new file mode 100644 index 000000000..017d024e8 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/transaction.py @@ -0,0 +1,210 @@ +"""Base clients.""" +import abc + +import attr + +from stac_fastapi.types import stac as stac_types + + +@attr.s # type:ignore +class BaseTransactionsClient(abc.ABC): + """Defines a pattern for implementing the STAC transaction extension.""" + + @abc.abstractmethod + def create_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item: + """Create a new item. + + Called with `POST /collections/{collection_id}/items`. + + Args: + item: the item + + Returns: + The item that was created. + + """ + ... + + @abc.abstractmethod + def update_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item: + """Perform a complete update on an existing item. + + Called with `PUT /collections/{collection_id}/items`. It is expected that this item already exists. The update + should do a diff against the saved item and perform any necessary updates. Partial updates are not supported + by the transactions extension. + + Args: + item: the item (must be complete) + + Returns: + The updated item. + """ + ... + + @abc.abstractmethod + def delete_item( + self, item_id: str, collection_id: str, **kwargs + ) -> stac_types.Item: + """Delete an item from a collection. + + Called with `DELETE /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + + Returns: + The deleted item. + """ + ... + + @abc.abstractmethod + def create_collection( + self, collection: stac_types.Collection, **kwargs + ) -> stac_types.Collection: + """Create a new collection. + + Called with `POST /collections`. + + Args: + collection: the collection + + Returns: + The collection that was created. + """ + ... + + @abc.abstractmethod + def update_collection( + self, collection: stac_types.Collection, **kwargs + ) -> stac_types.Collection: + """Perform a complete update on an existing collection. + + Called with `PUT /collections`. It is expected that this item already exists. The update should do a diff + against the saved collection and perform any necessary updates. Partial updates are not supported by the + transactions extension. + + Args: + collection: the collection (must be complete) + + Returns: + The updated collection. + """ + ... + + @abc.abstractmethod + def delete_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + """Delete a collection. + + Called with `DELETE /collections/{collection_id}` + + Args: + collection_id: id of the collection. + + Returns: + The deleted collection. + """ + ... + + +@attr.s # type:ignore +class AsyncBaseTransactionsClient(abc.ABC): + """Defines a pattern for implementing the STAC transaction extension.""" + + @abc.abstractmethod + async def create_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item: + """Create a new item. + + Called with `POST /collections/{collection_id}/items`. + + Args: + item: the item + + Returns: + The item that was created. + + """ + ... + + @abc.abstractmethod + async def update_item(self, item: stac_types.Item, **kwargs) -> stac_types.Item: + """Perform a complete update on an existing item. + + Called with `PUT /collections/{collection_id}/items`. It is expected that this item already exists. The update + should do a diff against the saved item and perform any necessary updates. Partial updates are not supported + by the transactions extension. + + Args: + item: the item (must be complete) + + Returns: + The updated item. + """ + ... + + @abc.abstractmethod + async def delete_item( + self, item_id: str, collection_id: str, **kwargs + ) -> stac_types.Item: + """Delete an item from a collection. + + Called with `DELETE /collections/{collection_id}/items/{item_id}` + + Args: + item_id: id of the item. + collection_id: id of the collection. + + Returns: + The deleted item. + """ + ... + + @abc.abstractmethod + async def create_collection( + self, collection: stac_types.Collection, **kwargs + ) -> stac_types.Collection: + """Create a new collection. + + Called with `POST /collections`. + + Args: + collection: the collection + + Returns: + The collection that was created. + """ + ... + + @abc.abstractmethod + async def update_collection( + self, collection: stac_types.Collection, **kwargs + ) -> stac_types.Collection: + """Perform a complete update on an existing collection. + + Called with `PUT /collections`. It is expected that this item already exists. The update should do a diff + against the saved collection and perform any necessary updates. Partial updates are not supported by the + transactions extension. + + Args: + collection: the collection (must be complete) + + Returns: + The updated collection. + """ + ... + + @abc.abstractmethod + async def delete_collection( + self, collection_id: str, **kwargs + ) -> stac_types.Collection: + """Delete a collection. + + Called with `DELETE /collections/{collection_id}` + + Args: + collection_id: id of the collection. + + Returns: + The deleted collection. + """ + ... diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index a5ffbb95f..5ce70dae7 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -29,6 +29,9 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" + # Path to JSON which defines the browseable hierarchy pending backend implementations + browseable_hierarchy_definition: Optional[str] = None + class Config: """model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" diff --git a/stac_fastapi/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 49f1323ba..352c6ea82 100644 --- a/stac_fastapi/types/stac_fastapi/types/conformance.py +++ b/stac_fastapi/types/stac_fastapi/types/conformance.py @@ -28,3 +28,7 @@ class OAFConformanceClasses(str, Enum): OAFConformanceClasses.OPEN_API, OAFConformanceClasses.GEOJSON, ] + +CHILDREN_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-rc.1/children" + +BROWSEABLE_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-rc.1/browseable" diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py deleted file mode 100644 index 7cc450a50..000000000 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ /dev/null @@ -1,748 +0,0 @@ -"""Base clients.""" -import abc -from datetime import datetime -from typing import Any, Dict, List, Optional, Union -from urllib.parse import urljoin - -import attr -from fastapi import Request -from stac_pydantic.links import Relations -from stac_pydantic.shared import MimeTypes -from stac_pydantic.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.search import BaseSearchPostRequest -from stac_fastapi.types.stac import Conformance - -NumType = Union[float, int] -StacType = Dict[str, Any] - - -@attr.s # type:ignore -class BaseTransactionsClient(abc.ABC): - """Defines a pattern for implementing the STAC API Transaction Extension.""" - - @abc.abstractmethod - def create_item( - self, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Create a new item. - - Called with `POST /collections/{collection_id}/items`. - - Args: - item: the item - collection_id: the id of the collection from the resource path - - Returns: - The item that was created. - - """ - ... - - @abc.abstractmethod - def update_item( - self, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Perform a complete update on an existing item. - - Called with `PUT /collections/{collection_id}/items`. It is expected that this item already exists. The update - should do a diff against the saved item and perform any necessary updates. Partial updates are not supported - by the transactions extension. - - Args: - item: the item (must be complete) - collection_id: the id of the collection from the resource path - - Returns: - The updated item. - """ - ... - - @abc.abstractmethod - def delete_item( - self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Delete an item from a collection. - - Called with `DELETE /collections/{collection_id}/items/{item_id}` - - Args: - item_id: id of the item. - collection_id: id of the collection. - - Returns: - The deleted item. - """ - ... - - @abc.abstractmethod - def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Create a new collection. - - Called with `POST /collections`. - - Args: - collection: the collection - - Returns: - The collection that was created. - """ - ... - - @abc.abstractmethod - def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Perform a complete update on an existing collection. - - Called with `PUT /collections`. It is expected that this item already exists. The update should do a diff - against the saved collection and perform any necessary updates. Partial updates are not supported by the - transactions extension. - - Args: - collection: the collection (must be complete) - collection_id: the id of the collection from the resource path - - Returns: - The updated collection. - """ - ... - - @abc.abstractmethod - def delete_collection( - self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Delete a collection. - - Called with `DELETE /collections/{collection_id}` - - Args: - collection_id: id of the collection. - - Returns: - The deleted collection. - """ - ... - - -@attr.s # type:ignore -class AsyncBaseTransactionsClient(abc.ABC): - """Defines a pattern for implementing the STAC transaction extension.""" - - @abc.abstractmethod - async def create_item( - self, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Create a new item. - - Called with `POST /collections/{collection_id}/items`. - - Args: - item: the item - - Returns: - The item that was created. - - """ - ... - - @abc.abstractmethod - async def update_item( - self, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Perform a complete update on an existing item. - - Called with `PUT /collections/{collection_id}/items`. It is expected that this item already exists. The update - should do a diff against the saved item and perform any necessary updates. Partial updates are not supported - by the transactions extension. - - Args: - item: the item (must be complete) - - Returns: - The updated item. - """ - ... - - @abc.abstractmethod - async def delete_item( - self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: - """Delete an item from a collection. - - Called with `DELETE /collections/{collection_id}/items/{item_id}` - - Args: - item_id: id of the item. - collection_id: id of the collection. - - Returns: - The deleted item. - """ - ... - - @abc.abstractmethod - async def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Create a new collection. - - Called with `POST /collections`. - - Args: - collection: the collection - - Returns: - The collection that was created. - """ - ... - - @abc.abstractmethod - async def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Perform a complete update on an existing collection. - - Called with `PUT /collections`. It is expected that this item already exists. The update should do a diff - against the saved collection and perform any necessary updates. Partial updates are not supported by the - transactions extension. - - Args: - collection: the collection (must be complete) - - Returns: - The updated collection. - """ - ... - - @abc.abstractmethod - async def delete_collection( - self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: - """Delete a collection. - - Called with `DELETE /collections/{collection_id}` - - Args: - collection_id: id of the collection. - - Returns: - The deleted collection. - """ - ... - - -@attr.s -class LandingPageMixin(abc.ABC): - """Create a STAC landing page (GET /).""" - - stac_version: str = attr.ib(default=STAC_VERSION) - landing_page_id: str = attr.ib(default="stac-fastapi") - title: str = attr.ib(default="stac-fastapi") - description: str = attr.ib(default="stac-fastapi") - - def _landing_page( - self, - base_url: str, - conformance_classes: List[str], - extension_schemas: List[str], - ) -> stac_types.LandingPage: - landing_page = stac_types.LandingPage( - type="Catalog", - id=self.landing_page_id, - title=self.title, - description=self.description, - stac_version=self.stac_version, - conformsTo=conformance_classes, - links=[ - { - "rel": Relations.self.value, - "type": MimeTypes.json, - "href": base_url, - }, - { - "rel": Relations.root.value, - "type": MimeTypes.json, - "href": base_url, - }, - { - "rel": "data", - "type": MimeTypes.json, - "href": urljoin(base_url, "collections"), - }, - { - "rel": Relations.conformance.value, - "type": MimeTypes.json, - "title": "STAC/WFS3 conformance classes implemented by this server", - "href": urljoin(base_url, "conformance"), - }, - { - "rel": Relations.search.value, - "type": MimeTypes.geojson, - "title": "STAC search", - "href": urljoin(base_url, "search"), - "method": "GET", - }, - { - "rel": Relations.search.value, - "type": MimeTypes.geojson, - "title": "STAC search", - "href": urljoin(base_url, "search"), - "method": "POST", - }, - ], - stac_extensions=extension_schemas, - ) - return landing_page - - -@attr.s # type:ignore -class BaseCoreClient(LandingPageMixin, abc.ABC): - """Defines a pattern for implementing STAC api core endpoints. - - Attributes: - extensions: list of registered api extensions. - """ - - base_conformance_classes: List[str] = attr.ib( - factory=lambda: BASE_CONFORMANCE_CLASSES - ) - extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) - post_request_model = attr.ib(default=BaseSearchPostRequest) - - def conformance_classes(self) -> List[str]: - """Generate conformance classes by adding extension conformance to base conformance classes.""" - base_conformance_classes = self.base_conformance_classes.copy() - - for extension in self.extensions: - extension_classes = getattr(extension, "conformance_classes", []) - base_conformance_classes.extend(extension_classes) - - return list(set(base_conformance_classes)) - - 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]) - - def list_conformance_classes(self): - """Return a list of conformance classes, including implemented extensions.""" - base_conformance = BASE_CONFORMANCE_CLASSES - - for extension in self.extensions: - extension_classes = getattr(extension, "conformance_classes", []) - base_conformance.extend(extension_classes) - - return base_conformance - - def landing_page(self, **kwargs) -> stac_types.LandingPage: - """Landing page. - - Called with `GET /`. - - Returns: - API landing page, serving as an entry point to the API. - """ - request: Request = kwargs["request"] - base_url = str(request.base_url) - extension_schemas = [ - schema.schema_href for schema in self.extensions if schema.schema_href - ] - request: Request = kwargs["request"] - base_url = str(request.base_url) - landing_page = self._landing_page( - base_url=base_url, - conformance_classes=self.conformance_classes(), - extension_schemas=extension_schemas, - ) - - # Add Collections links - collections = self.all_collections(request=kwargs["request"]) - for collection in collections["collections"]: - landing_page["links"].append( - { - "rel": Relations.child.value, - "type": MimeTypes.json.value, - "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), - } - ) - - # Add OpenAPI URL - landing_page["links"].append( - { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", - "title": "OpenAPI service description", - "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), - } - ) - - # Add human readable service-doc - landing_page["links"].append( - { - "rel": "service-doc", - "type": "text/html", - "title": "OpenAPI service documentation", - "href": urljoin(base_url, request.app.docs_url.lstrip("/")), - } - ) - - return landing_page - - def conformance(self, **kwargs) -> stac_types.Conformance: - """Conformance classes. - - Called with `GET /conformance`. - - Returns: - Conformance classes which the server conforms to. - """ - return Conformance(conformsTo=self.conformance_classes()) - - @abc.abstractmethod - def post_search( - self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: - """Cross catalog search (POST). - - Called with `POST /search`. - - Args: - search_request: search request parameters. - - Returns: - ItemCollection containing items which match the search criteria. - """ - ... - - @abc.abstractmethod - def get_search( - self, - collections: Optional[List[str]] = None, - ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, - limit: Optional[int] = 10, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - **kwargs, - ) -> stac_types.ItemCollection: - """Cross catalog search (GET). - - Called with `GET /search`. - - Returns: - ItemCollection containing items which match the search criteria. - """ - ... - - @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: - """Get item by id. - - Called with `GET /collections/{collection_id}/items/{item_id}`. - - Args: - item_id: Id of the item. - collection_id: Id of the collection. - - Returns: - Item. - """ - ... - - @abc.abstractmethod - def all_collections(self, **kwargs) -> stac_types.Collections: - """Get all available collections. - - Called with `GET /collections`. - - Returns: - A list of collections. - """ - ... - - @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: - """Get collection by id. - - Called with `GET /collections/{collection_id}`. - - Args: - collection_id: Id of the collection. - - Returns: - Collection. - """ - ... - - @abc.abstractmethod - def item_collection( - self, collection_id: str, limit: int = 10, token: str = None, **kwargs - ) -> stac_types.ItemCollection: - """Get all items from a specific collection. - - Called with `GET /collections/{collection_id}/items` - - Args: - collection_id: id of the collection. - limit: number of items to return. - token: pagination token. - - Returns: - An ItemCollection. - """ - ... - - -@attr.s # type:ignore -class AsyncBaseCoreClient(LandingPageMixin, abc.ABC): - """Defines a pattern for implementing STAC api core endpoints. - - Attributes: - extensions: list of registered api extensions. - """ - - base_conformance_classes: List[str] = attr.ib( - factory=lambda: BASE_CONFORMANCE_CLASSES - ) - extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) - post_request_model = attr.ib(default=BaseSearchPostRequest) - - def conformance_classes(self) -> List[str]: - """Generate conformance classes by adding extension conformance to base conformance classes.""" - conformance_classes = self.base_conformance_classes.copy() - - for extension in self.extensions: - extension_classes = getattr(extension, "conformance_classes", []) - conformance_classes.extend(extension_classes) - - return list(set(conformance_classes)) - - 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: - """Landing page. - - Called with `GET /`. - - Returns: - API landing page, serving as an entry point to the API. - """ - request: Request = kwargs["request"] - base_url = str(request.base_url) - extension_schemas = [ - schema.schema_href for schema in self.extensions if schema.schema_href - ] - landing_page = self._landing_page( - base_url=base_url, - conformance_classes=self.conformance_classes(), - extension_schemas=extension_schemas, - ) - collections = await self.all_collections(request=kwargs["request"]) - for collection in collections["collections"]: - landing_page["links"].append( - { - "rel": Relations.child.value, - "type": MimeTypes.json.value, - "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), - } - ) - - # Add OpenAPI URL - landing_page["links"].append( - { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", - "title": "OpenAPI service description", - "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), - } - ) - - # Add human readable service-doc - landing_page["links"].append( - { - "rel": "service-doc", - "type": "text/html", - "title": "OpenAPI service documentation", - "href": urljoin(base_url, request.app.docs_url.lstrip("/")), - } - ) - - return landing_page - - async def conformance(self, **kwargs) -> stac_types.Conformance: - """Conformance classes. - - Called with `GET /conformance`. - - Returns: - Conformance classes which the server conforms to. - """ - return Conformance(conformsTo=self.conformance_classes()) - - @abc.abstractmethod - async def post_search( - self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: - """Cross catalog search (POST). - - Called with `POST /search`. - - Args: - search_request: search request parameters. - - Returns: - ItemCollection containing items which match the search criteria. - """ - ... - - @abc.abstractmethod - async def get_search( - self, - collections: Optional[List[str]] = None, - ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, - datetime: Optional[Union[str, datetime]] = None, - limit: Optional[int] = 10, - query: Optional[str] = None, - token: Optional[str] = None, - fields: Optional[List[str]] = None, - sortby: Optional[str] = None, - **kwargs, - ) -> stac_types.ItemCollection: - """Cross catalog search (GET). - - Called with `GET /search`. - - Returns: - ItemCollection containing items which match the search criteria. - """ - ... - - @abc.abstractmethod - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> stac_types.Item: - """Get item by id. - - Called with `GET /collections/{collection_id}/items/{item_id}`. - - Args: - item_id: Id of the item. - collection_id: Id of the collection. - - Returns: - Item. - """ - ... - - @abc.abstractmethod - async def all_collections(self, **kwargs) -> stac_types.Collections: - """Get all available collections. - - Called with `GET /collections`. - - Returns: - A list of collections. - """ - ... - - @abc.abstractmethod - async def get_collection( - self, collection_id: str, **kwargs - ) -> stac_types.Collection: - """Get collection by id. - - Called with `GET /collections/{collection_id}`. - - Args: - collection_id: Id of the collection. - - Returns: - Collection. - """ - ... - - @abc.abstractmethod - async def item_collection( - self, collection_id: str, limit: int = 10, token: str = None, **kwargs - ) -> stac_types.ItemCollection: - """Get all items from a specific collection. - - Called with `GET /collections/{collection_id}/items` - - Args: - collection_id: id of the collection. - limit: number of items to return. - token: pagination token. - - Returns: - An ItemCollection. - """ - ... - - -@attr.s -class AsyncBaseFiltersClient(abc.ABC): - """Defines a pattern for implementing the STAC filter extension.""" - - async def get_queryables( - self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """Get the queryables available for the given collection_id. - - If collection_id is None, returns the intersection of all - queryables over all collections. - - This base implementation returns a blank queryable schema. This is not allowed - under OGC CQL but it is allowed by the STAC API Filter Extension - - https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables - """ - return { - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://example.org/queryables", - "type": "object", - "title": "Queryables for Example STAC API", - "description": "Queryable names for the example STAC API Item Search filter.", - "properties": {}, - } - - -@attr.s -class BaseFiltersClient(abc.ABC): - """Defines a pattern for implementing the STAC filter extension.""" - - def get_queryables( - self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """Get the queryables available for the given collection_id. - - If collection_id is None, returns the intersection of all - queryables over all collections. - - This base implementation returns a blank queryable schema. This is not allowed - under OGC CQL but it is allowed by the STAC API Filter Extension - - https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables - """ - return { - "$schema": "https://json-schema.org/draft/2019-09/schema", - "$id": "https://example.org/queryables", - "type": "object", - "title": "Queryables for Example STAC API", - "description": "Queryable names for the example STAC API Item Search filter.", - "properties": {}, - } diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py new file mode 100644 index 000000000..c84a57006 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -0,0 +1,210 @@ +"""Types and functions for browseable and children conformance class support. + +The types contained in this file provide the tooling necessary to support hierarchy within a +STAC API. The various nodes are recursively parsed from a dict which can (e.g.) be generated by +json.load. A minimal, STAC compliant catalog can be generated from a catalog node (refer to a +node's id field name: it will either be catalog_id or collection_id) with the browseable_catalog +function. +""" +import json +from typing import List, Optional, Tuple, TypedDict, Union +from urllib.parse import urljoin + +from stac_pydantic.links import Relations +from stac_pydantic.shared import MimeTypes + +from stac_fastapi.types import stac as stac_types + +ItemPath = Tuple[str, str] + + +class BrowseableNode(TypedDict): + """Abstract node for defining browseable hierarchy.""" + + children: List[Union["CatalogNode", "CollectionNode"]] + items: List[ItemPath] + + +class CollectionNode(BrowseableNode): + """Node for collections in browseable hierarchy.""" + + collection_id: str + + +class CatalogNode(BrowseableNode): + """Node for collections in browseable hierarchy.""" + + catalog_id: str + title: Optional[str] + description: Optional[str] + + +def find_catalog( + hierarchy: BrowseableNode, split_path: List[str] +) -> Optional[BrowseableNode]: + """Find catalog within a hierarchy at provided path or return None.""" + try: + for fork in split_path: + hierarchy = next( + node + for node in hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + return hierarchy + except StopIteration: + return None + + +def browseable_catalog_link( + node: BrowseableNode, base_url: str, catalog_path: str +) -> str: + """Produce browseable link to a child.""" + return { + "rel": Relations.child.value, + "type": MimeTypes.json, + "title": node.get("title") or node.get("catalog_id"), + "href": "/".join([base_url.strip("/"), "catalogs", catalog_path.strip("/")]), + } + + +def browseable_collection_link(node: BrowseableNode, base_url: str) -> str: + """Produce browseable link to a child.""" + return { + "rel": Relations.child.value, + "type": MimeTypes.json, + "title": node.get("title") or node.get("collection_id"), + "href": urljoin(base_url, f"collections/{node['collection_id']}"), + } + + +def browseable_item_link(item_path: ItemPath, base_url: str): + """Produce browseable link to an item.""" + return { + "rel": Relations.item.value, + "type": MimeTypes.json, + "href": urljoin(base_url, f"collections/{item_path[0]}/items/{item_path[1]}"), + } + + +def browseable_catalog_page( + catalog_node: CatalogNode, + base_url: str, + catalog_path: str, + stac_version: str, + conformance_classes: List[str], + extension_schemas: List[str], +) -> stac_types.LandingPage: + """Generate a STAC API landing page/catalog.""" + catalog_links = [ + browseable_catalog_link( + child, base_url, "/".join([catalog_path.strip("/"), child["catalog_id"]]) + ) + for child in catalog_node["children"] + if "catalog_id" in child + ] + collection_links = [ + browseable_collection_link(child, base_url) + for child in catalog_node["children"] + if "collection_id" in child + ] + children_links = catalog_links + collection_links + item_links = [ + browseable_item_link(item, base_url) for item in catalog_node["items"] + ] + + split_catalog_path = catalog_path.split("/") + if len(split_catalog_path) > 1: + parent_href = urljoin( + base_url, f"/catalogs/{'/'.join(split_catalog_path[:-1])}" + ) + else: + parent_href = base_url + + standard_links = [ + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}"), + }, + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": "data", + "type": MimeTypes.json, + "href": urljoin( + base_url, f"/catalogs/{catalog_path.strip('/')}/collections" + ), + }, + { + "rel": Relations.conformance.value, + "type": MimeTypes.json, + "title": "STAC/WFS3 conformance classes implemented by this api", + "href": urljoin( + base_url, f"/catalogs/{catalog_path.strip('/')}/conformance" + ), + }, + { + "rel": Relations.search.value, + "type": MimeTypes.geojson, + "title": "STAC search", + "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}/search"), + "method": "GET", + }, + { + "rel": Relations.search.value, + "type": MimeTypes.json, + "title": "STAC search", + "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}/search"), + "method": "POST", + }, + {"rel": Relations.parent.value, "type": MimeTypes.json, "href": parent_href}, + ] + + catalog_page = stac_types.LandingPage( + type="Catalog", + id=catalog_node["catalog_id"], + title=catalog_node["catalog_id"], + description=catalog_node["description"], + stac_version=stac_version, + conformsTo=conformance_classes, + links=standard_links + children_links + item_links, + stac_extensions=extension_schemas, + ) + return catalog_page + + +def parse_hierarchy(d: dict) -> BrowseableNode: + """Parse a dictionary as a BrowseableNode tree.""" + d_items = d.get("items") or [] + d_children = d.get("children") or [] + parsed_children = [parse_hierarchy(child) for child in d_children] + + if "collection_id" in d: + return CollectionNode( + collection_id=d["collection_id"], children=parsed_children, items=d_items + ) + elif "catalog_id" in d: + return CatalogNode( + catalog_id=d["catalog_id"], + children=parsed_children, + items=d_items, + title=d.get("title"), + description=d.get("description"), + ) + else: + return BrowseableNode(children=d_children, items=d_items) + + +def parse_hierarchy_file( + hierarchy_file_path: Optional[str], +) -> Optional[BrowseableNode]: + """Parse contents of a file as a BrowseableNode tree.""" + if hierarchy_file_path is not None: + with open(hierarchy_file_path, "r") as definition_file: + hierarchy_json = json.load(definition_file) + return parse_hierarchy(hierarchy_json) + else: + return None diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index a770de31b..c9ce27dda 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -78,3 +78,13 @@ class Collections(TypedDict, total=False): collections: List[Collection] links: List[Dict[str, Any]] + + +class Children(TypedDict, total=False): + """A catalog or collection's children. + + https://github.com/radiantearth/stac-api-spec/tree/master/children + """ + + children: List[Union[Catalog, Collection]] + links: List[Dict[str, Any]]