From f939811527821a64dd488e404654b5e860a57e67 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 18 Jan 2022 08:20:42 -0600 Subject: [PATCH 01/21] Init work towards children endpoint --- stac_fastapi/api/stac_fastapi/api/app.py | 22 ++++++++++++- .../pgstac/stac_fastapi/pgstac/core.py | 21 +++++++++++- .../stac_fastapi/sqlalchemy/core.py | 21 +++++++++++- stac_fastapi/types/stac_fastapi/types/core.py | 32 +++++++++++++++++++ stac_fastapi/types/stac_fastapi/types/stac.py | 10 ++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index a9f8a5542..bb298816e 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 Children, Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage from stac_pydantic.api.collections import Collections from stac_pydantic.version import STAC_VERSION @@ -266,6 +266,25 @@ def register_get_collection(self): ), ) + def register_get_collection_children(self): + """Register get collection children endpoint (GET /collection/{collection_id}/children). + + Returns: + None + """ + self.router.add_api_route( + name="Get Collection Children", + path="/collections/{collection_id}/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_collection_children, CollectionUri, self.response_class + ), + ) + def register_get_item_collection(self): """Register get item collection endpoint (GET /collection/{collection_id}/items). @@ -317,6 +336,7 @@ def register_core(self): self.register_get_search() self.register_get_collections() self.register_get_collection() + self.register_get_collection_children() self.register_get_item_collection() def customize_openapi(self) -> Optional[Dict[str, Any]]: diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index 4de102255..7e0bf7745 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -20,7 +20,13 @@ from stac_fastapi.pgstac.types.search import PgstacSearch from stac_fastapi.types.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.stac import ( + Children, + Collection, + Collections, + Item, + ItemCollection, +) NumType = Union[float, int] @@ -103,6 +109,19 @@ async def get_collection(self, collection_id: str, **kwargs) -> Collection: return Collection(**collection) + async def get_collection_children(self, collection_id: str, **kwargs) -> Children: + """Get children by parent collection id. + + Called with `GET /collections/{collection_id}/children`. + + Args: + collection_id: Id of the collection. + + Returns: + Children. + """ + return Children(children=[], links=[]) + async def _search_base( self, search_request: PgstacSearch, **kwargs: Any ) -> ItemCollection: diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index cd1ca9eea..d09093375 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -29,7 +29,13 @@ 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 +from stac_fastapi.types.stac import ( + Children, + Collection, + Collections, + Item, + ItemCollection, +) logger = logging.getLogger(__name__) @@ -98,6 +104,19 @@ def get_collection(self, collection_id: str, **kwargs) -> Collection: collection = self._lookup_id(collection_id, self.collection_table, session) return self.collection_serializer.db_to_stac(collection, base_url) + def get_collection_children(self, collection_id: str, **kwargs) -> Children: + """Get children by parent collection id. + + Called with `GET /collections/{collection_id}/children`. + + Args: + collection_id: Id of the collection. + + Returns: + Children. + """ + return Children(children=[], links=[]) + def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs ) -> ItemCollection: diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 7cc450a50..b4317d027 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -484,6 +484,22 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: """ ... + @abc.abstractmethod + def get_collection_children( + self, collection_id: str, **kwargs + ) -> stac_types.Children: + """Get children by parent collection id. + + Called with `GET /collections/{collection_id}/children`. + + Args: + collection_id: Id of the collection. + + Returns: + Children. + """ + ... + @abc.abstractmethod def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs @@ -675,6 +691,22 @@ async def get_collection( """ ... + @abc.abstractmethod + async def get_collection_children( + self, collection_id: str, **kwargs + ) -> stac_types.Children: + """Get children by parent's collection id. + + Called with `GET /collections/{collection_id}/children`. + + Args: + collection_id: Id of the collection. + + Returns: + Children. + """ + ... + @abc.abstractmethod async def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs 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]] From 8a1339cca433d9b2491eca1948bb499d13212b25 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Mon, 24 Jan 2022 18:07:24 -0600 Subject: [PATCH 02/21] Implement alternative backing for browsable features --- docker-compose.yml | 1 + stac_fastapi/api/stac_fastapi/api/app.py | 24 ++++- stac_fastapi/api/stac_fastapi/api/models.py | 11 ++- .../pgstac/stac_fastapi/pgstac/app.py | 10 +- .../sqlalchemy/stac_fastapi/sqlalchemy/app.py | 11 ++- stac_fastapi/testdata/joplin/hierarchy.json | 20 ++++ .../types/stac_fastapi/types/config.py | 3 + stac_fastapi/types/stac_fastapi/types/core.py | 80 +++++++++++++++ .../types/stac_fastapi/types/hierarchy.py | 97 +++++++++++++++++++ 9 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 stac_fastapi/testdata/joplin/hierarchy.json create mode 100644 stac_fastapi/types/stac_fastapi/types/hierarchy.py diff --git a/docker-compose.yml b/docker-compose.yml index 996bb6593..97683dc03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: - GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR - DB_MIN_CONN_SIZE=1 - DB_MAX_CONN_SIZE=1 + - BROWSABLE_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 bb298816e..1dfbf560d 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 Children, 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, @@ -37,6 +38,7 @@ 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 @@ -266,6 +268,25 @@ def register_get_collection(self): ), ) + 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_get_collection_children(self): """Register get collection children endpoint (GET /collection/{collection_id}/children). @@ -337,6 +358,7 @@ def register_core(self): self.register_get_collections() self.register_get_collection() self.register_get_collection_children() + self.register_get_catalog() self.register_get_item_collection() def customize_openapi(self) -> Optional[Dict[str, Any]]: diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3e4e01fe3..8aa17d7aa 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -97,16 +97,23 @@ def create_post_request_model( ) +@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/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py index b1dd94c57..f0286a492 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py @@ -1,4 +1,6 @@ """FastAPI application using PGStac.""" +import json + from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -16,6 +18,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 BrowsableNode, parse_hierarchy settings = Settings() extensions = [ @@ -30,13 +33,18 @@ TokenPaginationExtension(), ContextExtension(), ] +with open(settings.browsable_hierarchy_definition, "r") as definition_file: + hierarchy_json = json.load(definition_file) + hierarchy_definition: BrowsableNode = parse_hierarchy(hierarchy_json) 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_model=post_request_model, diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py index 29a0894ac..e42a4c580 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py @@ -1,4 +1,6 @@ """FastAPI application.""" +import json + from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import create_get_request_model, create_post_request_model from stac_fastapi.extensions.core import ( @@ -17,6 +19,7 @@ BulkTransactionsClient, TransactionsClient, ) +from stac_fastapi.types.hierarchy import BrowsableNode, parse_hierarchy settings = SqlalchemySettings() session = Session.create_from_settings(settings) @@ -29,6 +32,9 @@ TokenPaginationExtension(), ContextExtension(), ] +with open(settings.browsable_hierarchy_definition, "r") as definition_file: + hierarchy_json = json.load(definition_file) + hierarchy_definition: BrowsableNode = parse_hierarchy(hierarchy_json) post_request_model = create_post_request_model(extensions) @@ -36,7 +42,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/testdata/joplin/hierarchy.json b/stac_fastapi/testdata/joplin/hierarchy.json new file mode 100644 index 000000000..58ab63d6c --- /dev/null +++ b/stac_fastapi/testdata/joplin/hierarchy.json @@ -0,0 +1,20 @@ +{ + "children": [{ + "catalog_id": "joplin", + "title": "Joplin Item Catalog", + "description": "All joplin items", + "children": [], + "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"] + ] + }] +} \ No newline at end of file diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index a5ffbb95f..15c192be8 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 browsable hierarchy pending backend implementations + browsable_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/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index b4317d027..64cf67e06 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -14,6 +14,14 @@ 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.hierarchy import ( + BrowsableNode, + CatalogNode, + CollectionNode, + browsable_catalog, + browsable_child_link, + browsable_item_link, +) from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import Conformance @@ -315,6 +323,7 @@ class BaseCoreClient(LandingPageMixin, abc.ABC): ) extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) + hierarchy_definition: Optional[BrowsableNode] = attr.ib(default=None) def conformance_classes(self) -> List[str]: """Generate conformance classes by adding extension conformance to base conformance classes.""" @@ -373,6 +382,20 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: } ) + # Add links for browsable conformance + if self.hierarchy_definition is not None: + for child in self.hierarchy_definition["children"]: + if isinstance(child, CollectionNode): + landing_page["links"].append( + browsable_child_link(child, urljoin(base_url, "collections")) + ) + if isinstance(child, CatalogNode): + landing_page["links"].append( + browsable_child_link(child, urljoin(base_url, "catalogs")) + ) + for item in self.hierarchy_definition["items"]: + landing_page["links"].append(browsable_item_link(item, base_url)) + # Add OpenAPI URL landing_page["links"].append( { @@ -500,6 +523,23 @@ def get_collection_children( """ ... + 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 browsable hierarchy. + + Returns: + Catalog. + """ + split_path = catalog_path.split("/") + remaining_hierarchy = self.hierarchy_definition + for fork in split_path: + remaining_hierarchy = remaining_hierarchy[fork] + return browsable_catalog(catalog_path) + @abc.abstractmethod def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs @@ -532,6 +572,7 @@ class AsyncBaseCoreClient(LandingPageMixin, abc.ABC): ) extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) + hierarchy_definition: Optional[BrowsableNode] = attr.ib(default=None) def conformance_classes(self) -> List[str]: """Generate conformance classes by adding extension conformance to base conformance classes.""" @@ -565,6 +606,8 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: 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( @@ -576,6 +619,20 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: } ) + # Add links for browsable conformance + if self.hierarchy_definition is not None: + for child in self.hierarchy_definition["children"]: + if "collection_id" in child: + landing_page["links"].append( + browsable_child_link(child, urljoin(base_url, "collections")) + ) + if "catalog_id" in child: + landing_page["links"].append( + browsable_child_link(child, urljoin(base_url, "catalogs")) + ) + for item in self.hierarchy_definition["items"]: + landing_page["links"].append(browsable_item_link(item, base_url)) + # Add OpenAPI URL landing_page["links"].append( { @@ -707,6 +764,29 @@ async def get_collection_children( """ ... + 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 browsable hierarchy. + + Returns: + Catalog. + """ + request: Request = kwargs["request"] + base_url = str(request.base_url) + split_path = catalog_path.split("/") + remaining_hierarchy = self.hierarchy_definition + for fork in split_path: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if node["catalog_id"] == fork + ) + return browsable_catalog(remaining_hierarchy, base_url).dict(exclude_unset=True) + @abc.abstractmethod async def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs 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..92fa5cb0f --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -0,0 +1,97 @@ +"""Types for browsable and children implementation.""" +from typing import List, Optional, Tuple, TypedDict, Union +from urllib.parse import urljoin + +from stac_pydantic import Catalog +from stac_pydantic.links import Relations +from stac_pydantic.shared import MimeTypes +from stac_pydantic.version import STAC_VERSION + +ItemPath = Tuple[str, str] +NodeType = str + + +class BrowsableNode(TypedDict): + """Abstract node for defining browsable hierarchy.""" + + children: List[Union["CatalogNode", "CollectionNode"]] + items: List[ItemPath] + + +class CollectionNode(BrowsableNode): + """Node for collections in browsable hierarchy.""" + + collection_id: str + + +class CatalogNode(BrowsableNode): + """Node for collections in browsable hierarchy.""" + + catalog_id: str + title: Optional[str] + description: Optional[str] + + +def browsable_child_link(node: BrowsableNode, base_url: str) -> str: + """Produce browsable link to a child.""" + if "collection_id" in node: + 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']}"]), + } + elif "catalog_id" in node: + return { + "rel": Relations.child.value, + "type": MimeTypes.json, + "title": node.get("title") or node.get("catalog_id"), + "href": "/".join([base_url.strip("/"), node["catalog_id"]]), + } + + +def browsable_item_link(item_path: ItemPath, base_url): + """Produce browsable 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 browsable_catalog(node: CatalogNode, base_url: str) -> Catalog: + """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" + children_links = [ + browsable_child_link(child, base_url) for child in node["children"] + ] + item_links = [browsable_item_link(item, base_url) for item in node["items"]] + return Catalog( + id=node["catalog_id"], + description=node.get("description") + or f"Generated description for {node['catalog_id']}", + stac_version=STAC_VERSION, + links=children_links + item_links, + ) + + +def parse_hierarchy(d: dict) -> BrowsableNode: + """Parse a dictionary as a BrowsableNode tree.""" + if "children" in d: + children = [parse_hierarchy(child) for child in d["children"]] + else: + children = [] + + if "collection_id" in d: + return CollectionNode( + collection_id=d["collection_id"], children=children, items=d.get("items") + ) + elif "catalog_id" in d: + return CatalogNode( + catalog_id=d["catalog_id"], + children=children, + items=d.get("items"), + title=d.get("title"), + description=d.get("description"), + ) + else: + return BrowsableNode(children=children, items=d.get("items")) From 122ac4b50b142cc24c8f010c8d850888489642d1 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 09:39:30 -0600 Subject: [PATCH 03/21] Update links and support /children --- docker-compose.yml | 1 + stac_fastapi/api/stac_fastapi/api/app.py | 10 ++--- .../pgstac/stac_fastapi/pgstac/core.py | 44 +++++++++++++++---- .../stac_fastapi/sqlalchemy/core.py | 43 ++++++++++++++---- stac_fastapi/types/stac_fastapi/types/core.py | 24 ++++++---- .../types/stac_fastapi/types/hierarchy.py | 26 +++++++++-- 6 files changed, 115 insertions(+), 33 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 97683dc03..0e3c32b51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - POSTGRES_HOST_WRITER=database - POSTGRES_PORT=5432 - WEB_CONCURRENCY=10 + - BROWSABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json ports: - "8081:8081" volumes: diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 1dfbf560d..c18ac6290 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -287,22 +287,22 @@ def register_get_catalog(self): ), ) - def register_get_collection_children(self): + def register_get_root_children(self): """Register get collection children endpoint (GET /collection/{collection_id}/children). Returns: None """ self.router.add_api_route( - name="Get Collection Children", - path="/collections/{collection_id}/children", + 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_collection_children, CollectionUri, self.response_class + self.client.get_root_children, EmptyRequest, self.response_class ), ) @@ -357,7 +357,7 @@ def register_core(self): self.register_get_search() self.register_get_collections() self.register_get_collection() - self.register_get_collection_children() + self.register_get_root_children() self.register_get_catalog() self.register_get_item_collection() diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index 7e0bf7745..26bdc6afa 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -8,18 +8,18 @@ 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.errors import InvalidQueryParameter, NotFoundError +from stac_fastapi.types.hierarchy import browsable_catalog from stac_fastapi.types.stac import ( Children, Collection, @@ -109,18 +109,44 @@ async def get_collection(self, collection_id: str, **kwargs) -> Collection: return Collection(**collection) - async def get_collection_children(self, collection_id: str, **kwargs) -> Children: - """Get children by parent collection id. + async def get_root_children(self, **kwargs) -> Children: + """Get children of root catalog. - Called with `GET /collections/{collection_id}/children`. - - Args: - collection_id: Id of the collection. + Called with `GET /children`. Returns: Children. """ - return Children(children=[], links=[]) + request: Request = kwargs["request"] + base_url = str(request.base_url) + catalog_children = [ + browsable_catalog(child, child["catalog_id"], base_url).dict( + exclude_unset=True + ) + 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 diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index d09093375..0ea9bf70c 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -10,7 +10,7 @@ import geoalchemy2 as ga import sqlalchemy as sa import stac_pydantic -from fastapi import HTTPException +from fastapi import HTTPException, Request from pydantic import ValidationError from shapely.geometry import Polygon as ShapelyPolygon from shapely.geometry import shape @@ -28,6 +28,7 @@ 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.hierarchy import browsable_catalog from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import ( Children, @@ -104,18 +105,44 @@ def get_collection(self, collection_id: str, **kwargs) -> Collection: collection = self._lookup_id(collection_id, self.collection_table, session) return self.collection_serializer.db_to_stac(collection, base_url) - def get_collection_children(self, collection_id: str, **kwargs) -> Children: - """Get children by parent collection id. + def get_root_children(self, **kwargs) -> Children: + """Get children of root. - Called with `GET /collections/{collection_id}/children`. - - Args: - collection_id: Id of the collection. + Called with `GET /children`. Returns: Children. """ - return Children(children=[], links=[]) + request: Request = kwargs["request"] + base_url = str(request.base_url) + catalog_children = [ + browsable_catalog(child, child["catalog_id"], base_url).dict( + exclude_unset=True + ) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + 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 Children( + children=catalog_children + collection_children["collections"], links=links + ) def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 64cf67e06..ab73b1b40 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -508,9 +508,7 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: ... @abc.abstractmethod - def get_collection_children( - self, collection_id: str, **kwargs - ) -> stac_types.Children: + def get_root_children(self, **kwargs) -> stac_types.Children: """Get children by parent collection id. Called with `GET /collections/{collection_id}/children`. @@ -534,11 +532,19 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: Returns: Catalog. """ + request: Request = kwargs["request"] + base_url = str(request.base_url) split_path = catalog_path.split("/") remaining_hierarchy = self.hierarchy_definition for fork in split_path: - remaining_hierarchy = remaining_hierarchy[fork] - return browsable_catalog(catalog_path) + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if node["catalog_id"] == fork + ) + return browsable_catalog(remaining_hierarchy, catalog_path, base_url).dict( + exclude_unset=True + ) @abc.abstractmethod def item_collection( @@ -749,12 +755,12 @@ async def get_collection( ... @abc.abstractmethod - async def get_collection_children( + async def get_root_children( self, collection_id: str, **kwargs ) -> stac_types.Children: """Get children by parent's collection id. - Called with `GET /collections/{collection_id}/children`. + Called with `GET /children`. Args: collection_id: Id of the collection. @@ -785,7 +791,9 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browsable_catalog(remaining_hierarchy, base_url).dict(exclude_unset=True) + return browsable_catalog(remaining_hierarchy, catalog_path, base_url).dict( + exclude_unset=True + ) @abc.abstractmethod async def item_collection( diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 92fa5cb0f..02aa30d2b 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -1,4 +1,11 @@ -"""Types for browsable and children implementation.""" +"""Types and functions for browsable 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 browsable_catalog +function. +""" from typing import List, Optional, Tuple, TypedDict, Union from urllib.parse import urljoin @@ -59,18 +66,31 @@ def browsable_item_link(item_path: ItemPath, base_url): } -def browsable_catalog(node: CatalogNode, base_url: str) -> Catalog: +def browsable_catalog(node: CatalogNode, catalog_path: str, base_url: str) -> Catalog: """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" children_links = [ browsable_child_link(child, base_url) for child in node["children"] ] item_links = [browsable_item_link(item, base_url) for item in node["items"]] + standard_links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}"), + }, + ] return Catalog( + type="Catalog", id=node["catalog_id"], description=node.get("description") or f"Generated description for {node['catalog_id']}", stac_version=STAC_VERSION, - links=children_links + item_links, + links=children_links + item_links + standard_links, ) From 546b6f0e3ca6710ebc0d6de18761180ca0c6d9d1 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 10:21:19 -0600 Subject: [PATCH 04/21] Add example of nested catalog --- .../pgstac/stac_fastapi/pgstac/core.py | 2 +- .../stac_fastapi/sqlalchemy/core.py | 2 +- stac_fastapi/testdata/joplin/hierarchy.json | 12 +++- stac_fastapi/types/stac_fastapi/types/core.py | 23 ++++---- .../types/stac_fastapi/types/hierarchy.py | 59 +++++++++++++------ 5 files changed, 65 insertions(+), 33 deletions(-) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index 26bdc6afa..dbddbd196 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -120,7 +120,7 @@ async def get_root_children(self, **kwargs) -> Children: request: Request = kwargs["request"] base_url = str(request.base_url) catalog_children = [ - browsable_catalog(child, child["catalog_id"], base_url).dict( + browsable_catalog(child, base_url, child["catalog_id"]).dict( exclude_unset=True ) for child in self.hierarchy_definition["children"] diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index 0ea9bf70c..7fc46a3ec 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -116,7 +116,7 @@ def get_root_children(self, **kwargs) -> Children: request: Request = kwargs["request"] base_url = str(request.base_url) catalog_children = [ - browsable_catalog(child, child["catalog_id"], base_url).dict( + browsable_catalog(child, base_url, child["catalog_id"]).dict( exclude_unset=True ) for child in self.hierarchy_definition["children"] diff --git a/stac_fastapi/testdata/joplin/hierarchy.json b/stac_fastapi/testdata/joplin/hierarchy.json index 58ab63d6c..e4f358eef 100644 --- a/stac_fastapi/testdata/joplin/hierarchy.json +++ b/stac_fastapi/testdata/joplin/hierarchy.json @@ -3,7 +3,17 @@ "catalog_id": "joplin", "title": "Joplin Item Catalog", "description": "All joplin items", - "children": [], + "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"] + ] + }], "items": [ ["joplin", "fe916452-ba6f-4631-9154-c249924a122d"], ["joplin", "f7f164c9-cfdf-436d-a3f0-69864c38ba2a"], diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index ab73b1b40..3efb04f86 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -16,10 +16,9 @@ from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.hierarchy import ( BrowsableNode, - CatalogNode, - CollectionNode, browsable_catalog, - browsable_child_link, + browsable_catalog_link, + browsable_collection_link, browsable_item_link, ) from stac_fastapi.types.search import BaseSearchPostRequest @@ -385,13 +384,15 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Add links for browsable conformance if self.hierarchy_definition is not None: for child in self.hierarchy_definition["children"]: - if isinstance(child, CollectionNode): + if "collection_id" in child: landing_page["links"].append( - browsable_child_link(child, urljoin(base_url, "collections")) + browsable_collection_link( + child, urljoin(base_url, "collections") + ) ) - if isinstance(child, CatalogNode): + if "catalog_id" in child: landing_page["links"].append( - browsable_child_link(child, urljoin(base_url, "catalogs")) + browsable_catalog_link(child, urljoin(base_url, "catalogs")) ) for item in self.hierarchy_definition["items"]: landing_page["links"].append(browsable_item_link(item, base_url)) @@ -542,7 +543,7 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browsable_catalog(remaining_hierarchy, catalog_path, base_url).dict( + return browsable_catalog(remaining_hierarchy, base_url, catalog_path).dict( exclude_unset=True ) @@ -630,11 +631,11 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: for child in self.hierarchy_definition["children"]: if "collection_id" in child: landing_page["links"].append( - browsable_child_link(child, urljoin(base_url, "collections")) + browsable_collection_link(child, base_url) ) if "catalog_id" in child: landing_page["links"].append( - browsable_child_link(child, urljoin(base_url, "catalogs")) + browsable_catalog_link(child, base_url) ) for item in self.hierarchy_definition["items"]: landing_page["links"].append(browsable_item_link(item, base_url)) @@ -791,7 +792,7 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browsable_catalog(remaining_hierarchy, catalog_path, base_url).dict( + return browsable_catalog(remaining_hierarchy, base_url, catalog_path).dict( exclude_unset=True ) diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 02aa30d2b..242dd7fb6 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -39,25 +39,34 @@ class CatalogNode(BrowsableNode): description: Optional[str] -def browsable_child_link(node: BrowsableNode, base_url: str) -> str: +def browsable_catalog_link( + node: BrowsableNode, base_url: str, catalog_path: Optional[str] +) -> str: """Produce browsable link to a child.""" - if "collection_id" in node: - 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']}"]), - } - elif "catalog_id" in node: - return { - "rel": Relations.child.value, - "type": MimeTypes.json, - "title": node.get("title") or node.get("catalog_id"), - "href": "/".join([base_url.strip("/"), node["catalog_id"]]), - } + print("BASE CAT URL", base_url, catalog_path) + catalog_path = catalog_path or "" + return { + "rel": Relations.child.value, + "type": MimeTypes.json, + "title": node.get("title") or node.get("catalog_id"), + "href": "/".join( + [base_url.strip("/"), catalog_path.strip("/"), node["catalog_id"]] + ), + } + + +def browsable_collection_link(node: BrowsableNode, base_url: str) -> str: + """Produce browsable link to a child.""" + print("BASE COLL URL", base_url) + 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 browsable_item_link(item_path: ItemPath, base_url): +def browsable_item_link(item_path: ItemPath, base_url: str): """Produce browsable link to an item.""" return { "rel": Relations.item.value, @@ -66,11 +75,23 @@ def browsable_item_link(item_path: ItemPath, base_url): } -def browsable_catalog(node: CatalogNode, catalog_path: str, base_url: str) -> Catalog: +def browsable_catalog( + node: CatalogNode, base_url: str, catalog_path: Optional[str] +) -> Catalog: """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" - children_links = [ - browsable_child_link(child, base_url) for child in node["children"] + catalog_path = catalog_path or "" + print("LINK PATHING", base_url, catalog_path, node["catalog_id"]) + catalog_links = [ + browsable_catalog_link(child, base_url, f"/catalogs/{catalog_path.strip('/')}") + for child in node["children"] + if "catalog_id" in child + ] + collection_links = [ + browsable_collection_link(child, base_url) + for child in node["children"] + if "collection_id" in child ] + children_links = catalog_links + collection_links item_links = [browsable_item_link(item, base_url) for item in node["items"]] standard_links = [ { From ab06b8b883f2ee97cc50592407fa69eeca0143bd Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 10:24:32 -0600 Subject: [PATCH 05/21] Remove printlns --- stac_fastapi/types/stac_fastapi/types/hierarchy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 242dd7fb6..0e09c4b36 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -43,7 +43,6 @@ def browsable_catalog_link( node: BrowsableNode, base_url: str, catalog_path: Optional[str] ) -> str: """Produce browsable link to a child.""" - print("BASE CAT URL", base_url, catalog_path) catalog_path = catalog_path or "" return { "rel": Relations.child.value, @@ -57,7 +56,6 @@ def browsable_catalog_link( def browsable_collection_link(node: BrowsableNode, base_url: str) -> str: """Produce browsable link to a child.""" - print("BASE COLL URL", base_url) return { "rel": Relations.child.value, "type": MimeTypes.json, @@ -80,7 +78,6 @@ def browsable_catalog( ) -> Catalog: """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" catalog_path = catalog_path or "" - print("LINK PATHING", base_url, catalog_path, node["catalog_id"]) catalog_links = [ browsable_catalog_link(child, base_url, f"/catalogs/{catalog_path.strip('/')}") for child in node["children"] From e9861698cc56961eed142b436fb32ef0ee255ddd Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 10:56:03 -0600 Subject: [PATCH 06/21] Simplify url construction --- stac_fastapi/testdata/joplin/hierarchy.json | 3 +- stac_fastapi/types/stac_fastapi/types/core.py | 4 +- .../types/stac_fastapi/types/hierarchy.py | 41 +++++++++++-------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/stac_fastapi/testdata/joplin/hierarchy.json b/stac_fastapi/testdata/joplin/hierarchy.json index e4f358eef..1d99c54ef 100644 --- a/stac_fastapi/testdata/joplin/hierarchy.json +++ b/stac_fastapi/testdata/joplin/hierarchy.json @@ -26,5 +26,6 @@ ["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/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 3efb04f86..d50ec5ed9 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -392,7 +392,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: ) if "catalog_id" in child: landing_page["links"].append( - browsable_catalog_link(child, urljoin(base_url, "catalogs")) + browsable_catalog_link(child, urljoin(base_url, "catalogs"), "") ) for item in self.hierarchy_definition["items"]: landing_page["links"].append(browsable_item_link(item, base_url)) @@ -635,7 +635,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) if "catalog_id" in child: landing_page["links"].append( - browsable_catalog_link(child, base_url) + browsable_catalog_link(child, base_url, child["catalog_id"]) ) for item in self.hierarchy_definition["items"]: landing_page["links"].append(browsable_item_link(item, base_url)) diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 0e09c4b36..66e81ac1a 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -40,17 +40,14 @@ class CatalogNode(BrowsableNode): def browsable_catalog_link( - node: BrowsableNode, base_url: str, catalog_path: Optional[str] + node: BrowsableNode, base_url: str, catalog_path: str ) -> str: """Produce browsable link to a child.""" - catalog_path = catalog_path or "" return { "rel": Relations.child.value, "type": MimeTypes.json, "title": node.get("title") or node.get("catalog_id"), - "href": "/".join( - [base_url.strip("/"), catalog_path.strip("/"), node["catalog_id"]] - ), + "href": "/".join([base_url.strip("/"), "catalogs", catalog_path.strip("/")]), } @@ -73,13 +70,12 @@ def browsable_item_link(item_path: ItemPath, base_url: str): } -def browsable_catalog( - node: CatalogNode, base_url: str, catalog_path: Optional[str] -) -> Catalog: +def browsable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> Catalog: """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" - catalog_path = catalog_path or "" catalog_links = [ - browsable_catalog_link(child, base_url, f"/catalogs/{catalog_path.strip('/')}") + browsable_catalog_link( + child, base_url, "/".join([catalog_path.strip("/"), child["catalog_id"]]) + ) for child in node["children"] if "catalog_id" in child ] @@ -90,6 +86,15 @@ def browsable_catalog( ] children_links = catalog_links + collection_links item_links = [browsable_item_link(item, base_url) for item in 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.root.value, @@ -101,6 +106,7 @@ def browsable_catalog( "type": MimeTypes.json, "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}"), }, + {"rel": Relations.parent.value, "type": MimeTypes.json, "href": parent_href}, ] return Catalog( type="Catalog", @@ -114,22 +120,21 @@ def browsable_catalog( def parse_hierarchy(d: dict) -> BrowsableNode: """Parse a dictionary as a BrowsableNode tree.""" - if "children" in d: - children = [parse_hierarchy(child) for child in d["children"]] - else: - children = [] + 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=children, items=d.get("items") + collection_id=d["collection_id"], children=parsed_children, items=d_items ) elif "catalog_id" in d: return CatalogNode( catalog_id=d["catalog_id"], - children=children, - items=d.get("items"), + children=parsed_children, + items=d_items, title=d.get("title"), description=d.get("description"), ) else: - return BrowsableNode(children=children, items=d.get("items")) + return BrowsableNode(children=d_children, items=d_items) From 932dc4d023b1c97c234d1f4cf2d508d2b68f199c Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 11:09:53 -0600 Subject: [PATCH 07/21] Add conformance classes --- .../types/stac_fastapi/types/conformance.py | 4 ++++ stac_fastapi/types/stac_fastapi/types/core.py | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/stac_fastapi/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 49f1323ba..3253d2f20 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-beta.5/children" + +BROWSEABLE_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-beta.5/browseable" diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index d50ec5ed9..34c75930e 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -12,7 +12,11 @@ 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.conformance import ( + BASE_CONFORMANCE_CLASSES, + BROWSEABLE_CONFORMANCE_CLASS, + CHILDREN_CONFORMANCE_CLASS, +) from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.hierarchy import ( BrowsableNode, @@ -327,6 +331,10 @@ class BaseCoreClient(LandingPageMixin, abc.ABC): 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() + base_conformance_classes = base_conformance_classes + [ + BROWSEABLE_CONFORMANCE_CLASS, + CHILDREN_CONFORMANCE_CLASS, + ] for extension in self.extensions: extension_classes = getattr(extension, "conformance_classes", []) @@ -585,6 +593,11 @@ def conformance_classes(self) -> List[str]: """Generate conformance classes by adding extension conformance to base conformance classes.""" conformance_classes = self.base_conformance_classes.copy() + conformance_classes = conformance_classes + [ + BROWSEABLE_CONFORMANCE_CLASS, + CHILDREN_CONFORMANCE_CLASS, + ] + for extension in self.extensions: extension_classes = getattr(extension, "conformance_classes", []) conformance_classes.extend(extension_classes) From d943c90646994dc8bed1d6bb777e14354ab2c700 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 15:04:54 -0600 Subject: [PATCH 08/21] Spell browseable correctly --- docker-compose.yml | 4 +- stac_fastapi/api/stac_fastapi/api/app.py | 4 +- .../pgstac/stac_fastapi/pgstac/app.py | 6 +- .../pgstac/stac_fastapi/pgstac/core.py | 4 +- .../sqlalchemy/stac_fastapi/sqlalchemy/app.py | 6 +- .../stac_fastapi/sqlalchemy/core.py | 4 +- .../types/stac_fastapi/types/config.py | 4 +- stac_fastapi/types/stac_fastapi/types/core.py | 81 ++++++++++++------- .../types/stac_fastapi/types/hierarchy.py | 46 +++++------ 9 files changed, 91 insertions(+), 68 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0e3c32b51..1dbab7b89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: - POSTGRES_HOST_WRITER=database - POSTGRES_PORT=5432 - WEB_CONCURRENCY=10 - - BROWSABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json + - BROWSEABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json ports: - "8081:8081" volumes: @@ -51,7 +51,7 @@ services: - GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR - DB_MIN_CONN_SIZE=1 - DB_MAX_CONN_SIZE=1 - - BROWSABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json + - 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 c18ac6290..75bd42e48 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -357,10 +357,12 @@ def register_core(self): self.register_get_search() self.register_get_collections() self.register_get_collection() - self.register_get_root_children() self.register_get_catalog() self.register_get_item_collection() + if self.settings.browseable_hierarchy_definition is not None: + self.register_get_root_children() + def customize_openapi(self) -> Optional[Dict[str, Any]]: """Customize openapi schema.""" if self.app.openapi_schema: diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py index f0286a492..8dbf19673 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py @@ -18,7 +18,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 BrowsableNode, parse_hierarchy +from stac_fastapi.types.hierarchy import BrowseableNode, parse_hierarchy settings = Settings() extensions = [ @@ -33,9 +33,9 @@ TokenPaginationExtension(), ContextExtension(), ] -with open(settings.browsable_hierarchy_definition, "r") as definition_file: +with open(settings.browseable_hierarchy_definition, "r") as definition_file: hierarchy_json = json.load(definition_file) - hierarchy_definition: BrowsableNode = parse_hierarchy(hierarchy_json) + hierarchy_definition: BrowseableNode = parse_hierarchy(hierarchy_json) post_request_model = create_post_request_model(extensions, base_model=PgstacSearch) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index dbddbd196..c66094870 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -19,7 +19,7 @@ from stac_fastapi.pgstac.types.search import PgstacSearch from stac_fastapi.types.core import AsyncBaseCoreClient from stac_fastapi.types.errors import InvalidQueryParameter, NotFoundError -from stac_fastapi.types.hierarchy import browsable_catalog +from stac_fastapi.types.hierarchy import browseable_catalog from stac_fastapi.types.stac import ( Children, Collection, @@ -120,7 +120,7 @@ async def get_root_children(self, **kwargs) -> Children: request: Request = kwargs["request"] base_url = str(request.base_url) catalog_children = [ - browsable_catalog(child, base_url, child["catalog_id"]).dict( + browseable_catalog(child, base_url, child["catalog_id"]).dict( exclude_unset=True ) for child in self.hierarchy_definition["children"] diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py index e42a4c580..80628b928 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py @@ -19,7 +19,7 @@ BulkTransactionsClient, TransactionsClient, ) -from stac_fastapi.types.hierarchy import BrowsableNode, parse_hierarchy +from stac_fastapi.types.hierarchy import BrowseableNode, parse_hierarchy settings = SqlalchemySettings() session = Session.create_from_settings(settings) @@ -32,9 +32,9 @@ TokenPaginationExtension(), ContextExtension(), ] -with open(settings.browsable_hierarchy_definition, "r") as definition_file: +with open(settings.browseable_hierarchy_definition, "r") as definition_file: hierarchy_json = json.load(definition_file) - hierarchy_definition: BrowsableNode = parse_hierarchy(hierarchy_json) + hierarchy_definition: BrowseableNode = parse_hierarchy(hierarchy_json) post_request_model = create_post_request_model(extensions) diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index 7fc46a3ec..e4bfca729 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -28,7 +28,7 @@ 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.hierarchy import browsable_catalog +from stac_fastapi.types.hierarchy import browseable_catalog from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import ( Children, @@ -116,7 +116,7 @@ def get_root_children(self, **kwargs) -> Children: request: Request = kwargs["request"] base_url = str(request.base_url) catalog_children = [ - browsable_catalog(child, base_url, child["catalog_id"]).dict( + browseable_catalog(child, base_url, child["catalog_id"]).dict( exclude_unset=True ) for child in self.hierarchy_definition["children"] diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 15c192be8..5ce70dae7 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -29,8 +29,8 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" - # Path to JSON which defines the browsable hierarchy pending backend implementations - browsable_hierarchy_definition: Optional[str] = None + # 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/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 34c75930e..2b4c63189 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -19,11 +19,11 @@ ) from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.hierarchy import ( - BrowsableNode, - browsable_catalog, - browsable_catalog_link, - browsable_collection_link, - browsable_item_link, + BrowseableNode, + browseable_catalog, + browseable_catalog_link, + browseable_collection_link, + browseable_item_link, ) from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import Conformance @@ -326,21 +326,20 @@ class BaseCoreClient(LandingPageMixin, abc.ABC): ) extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) - hierarchy_definition: Optional[BrowsableNode] = attr.ib(default=None) + 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.""" - base_conformance_classes = self.base_conformance_classes.copy() - base_conformance_classes = base_conformance_classes + [ - BROWSEABLE_CONFORMANCE_CLASS, - CHILDREN_CONFORMANCE_CLASS, - ] + 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", []) - base_conformance_classes.extend(extension_classes) + conformance_classes.extend(extension_classes) - return list(set(base_conformance_classes)) + return list(set(conformance_classes)) def extension_is_enabled(self, extension: str) -> bool: """Check if an api extension is enabled.""" @@ -389,21 +388,34 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: } ) - # Add links for browsable conformance + # Add links for browseable and children conformance if self.hierarchy_definition is not None: + # Children + landing_page["links"].append( + { + "rel": "children", # todo: add this relation to stac-pydantic + "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( - browsable_collection_link( + browseable_collection_link( child, urljoin(base_url, "collections") ) ) if "catalog_id" in child: landing_page["links"].append( - browsable_catalog_link(child, urljoin(base_url, "catalogs"), "") + browseable_catalog_link( + child, urljoin(base_url, "catalogs"), "" + ) ) for item in self.hierarchy_definition["items"]: - landing_page["links"].append(browsable_item_link(item, base_url)) + landing_page["links"].append(browseable_item_link(item, base_url)) # Add OpenAPI URL landing_page["links"].append( @@ -536,7 +548,7 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: Called with `GET /catalogs/{catalog_path}`. Args: - catalog_path: The full path of the catalog in the browsable hierarchy. + catalog_path: The full path of the catalog in the browseable hierarchy. Returns: Catalog. @@ -551,7 +563,7 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browsable_catalog(remaining_hierarchy, base_url, catalog_path).dict( + return browseable_catalog(remaining_hierarchy, base_url, catalog_path).dict( exclude_unset=True ) @@ -587,16 +599,15 @@ class AsyncBaseCoreClient(LandingPageMixin, abc.ABC): ) extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) - hierarchy_definition: Optional[BrowsableNode] = attr.ib(default=None) + 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() - conformance_classes = conformance_classes + [ - BROWSEABLE_CONFORMANCE_CLASS, - CHILDREN_CONFORMANCE_CLASS, - ] + 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", []) @@ -639,19 +650,29 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: } ) - # Add links for browsable conformance + # Add links for children and browseable conformance if self.hierarchy_definition is not None: + # Children + landing_page["links"].append( + { + "rel": "children", # todo: add this relation to stac-pydantic + "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( - browsable_collection_link(child, base_url) + browseable_collection_link(child, base_url) ) if "catalog_id" in child: landing_page["links"].append( - browsable_catalog_link(child, base_url, child["catalog_id"]) + browseable_catalog_link(child, base_url, child["catalog_id"]) ) for item in self.hierarchy_definition["items"]: - landing_page["links"].append(browsable_item_link(item, base_url)) + landing_page["links"].append(browseable_item_link(item, base_url)) # Add OpenAPI URL landing_page["links"].append( @@ -790,7 +811,7 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: Called with `GET /catalogs/{catalog_path}`. Args: - catalog_path: The full path of the catalog in the browsable hierarchy. + catalog_path: The full path of the catalog in the browseable hierarchy. Returns: Catalog. @@ -805,7 +826,7 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browsable_catalog(remaining_hierarchy, base_url, catalog_path).dict( + return browseable_catalog(remaining_hierarchy, base_url, catalog_path).dict( exclude_unset=True ) diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 66e81ac1a..0858cdabd 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -1,9 +1,9 @@ -"""Types and functions for browsable and children conformance class support. +"""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 browsable_catalog +node's id field name: it will either be catalog_id or collection_id) with the browseable_catalog function. """ from typing import List, Optional, Tuple, TypedDict, Union @@ -18,31 +18,31 @@ NodeType = str -class BrowsableNode(TypedDict): - """Abstract node for defining browsable hierarchy.""" +class BrowseableNode(TypedDict): + """Abstract node for defining browseable hierarchy.""" children: List[Union["CatalogNode", "CollectionNode"]] items: List[ItemPath] -class CollectionNode(BrowsableNode): - """Node for collections in browsable hierarchy.""" +class CollectionNode(BrowseableNode): + """Node for collections in browseable hierarchy.""" collection_id: str -class CatalogNode(BrowsableNode): - """Node for collections in browsable hierarchy.""" +class CatalogNode(BrowseableNode): + """Node for collections in browseable hierarchy.""" catalog_id: str title: Optional[str] description: Optional[str] -def browsable_catalog_link( - node: BrowsableNode, base_url: str, catalog_path: str +def browseable_catalog_link( + node: BrowseableNode, base_url: str, catalog_path: str ) -> str: - """Produce browsable link to a child.""" + """Produce browseable link to a child.""" return { "rel": Relations.child.value, "type": MimeTypes.json, @@ -51,8 +51,8 @@ def browsable_catalog_link( } -def browsable_collection_link(node: BrowsableNode, base_url: str) -> str: - """Produce browsable link to a child.""" +def browseable_collection_link(node: BrowseableNode, base_url: str) -> str: + """Produce browseable link to a child.""" return { "rel": Relations.child.value, "type": MimeTypes.json, @@ -61,8 +61,8 @@ def browsable_collection_link(node: BrowsableNode, base_url: str) -> str: } -def browsable_item_link(item_path: ItemPath, base_url: str): - """Produce browsable link to an item.""" +def browseable_item_link(item_path: ItemPath, base_url: str): + """Produce browseable link to an item.""" return { "rel": Relations.item.value, "type": MimeTypes.json, @@ -70,22 +70,22 @@ def browsable_item_link(item_path: ItemPath, base_url: str): } -def browsable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> Catalog: - """Generate a catalog based on a CatalogNode in a BrowsableNode tree.""" +def browseable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> Catalog: + """Generate a catalog based on a CatalogNode in a BrowseableNode tree.""" catalog_links = [ - browsable_catalog_link( + browseable_catalog_link( child, base_url, "/".join([catalog_path.strip("/"), child["catalog_id"]]) ) for child in node["children"] if "catalog_id" in child ] collection_links = [ - browsable_collection_link(child, base_url) + browseable_collection_link(child, base_url) for child in node["children"] if "collection_id" in child ] children_links = catalog_links + collection_links - item_links = [browsable_item_link(item, base_url) for item in node["items"]] + item_links = [browseable_item_link(item, base_url) for item in node["items"]] split_catalog_path = catalog_path.split("/") if len(split_catalog_path) > 1: @@ -118,8 +118,8 @@ def browsable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> Ca ) -def parse_hierarchy(d: dict) -> BrowsableNode: - """Parse a dictionary as a BrowsableNode tree.""" +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] @@ -137,4 +137,4 @@ def parse_hierarchy(d: dict) -> BrowsableNode: description=d.get("description"), ) else: - return BrowsableNode(children=d_children, items=d_items) + return BrowseableNode(children=d_children, items=d_items) From f4c5620ae48114a75b7fd6315d08e473d1fc0bdf Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 25 Jan 2022 15:54:24 -0600 Subject: [PATCH 09/21] Add full page for catalogs to support API functionality --- .../pgstac/stac_fastapi/pgstac/core.py | 14 +++- .../stac_fastapi/sqlalchemy/core.py | 16 +++- stac_fastapi/testdata/joplin/hierarchy.json | 4 + stac_fastapi/types/stac_fastapi/types/core.py | 76 ++++++++++++------- .../types/stac_fastapi/types/hierarchy.py | 75 +++++++++++++----- 5 files changed, 134 insertions(+), 51 deletions(-) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index c66094870..d42193bd7 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -19,7 +19,7 @@ from stac_fastapi.pgstac.types.search import PgstacSearch from stac_fastapi.types.core import AsyncBaseCoreClient from stac_fastapi.types.errors import InvalidQueryParameter, NotFoundError -from stac_fastapi.types.hierarchy import browseable_catalog +from stac_fastapi.types.hierarchy import browseable_catalog_page from stac_fastapi.types.stac import ( Children, Collection, @@ -119,9 +119,17 @@ async def get_root_children(self, **kwargs) -> 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(child, base_url, child["catalog_id"]).dict( - exclude_unset=True + 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 diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index e4bfca729..462cf2f3d 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -28,7 +28,7 @@ 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.hierarchy import browseable_catalog +from stac_fastapi.types.hierarchy import browseable_catalog_page from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import ( Children, @@ -115,10 +115,18 @@ def get_root_children(self, **kwargs) -> 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(child, base_url, child["catalog_id"]).dict( - exclude_unset=True - ) + browseable_catalog_page( + child, + base_url, + child["catalog_id"], + self.stac_version, + self.conformance_classes(), + extension_schemas, + ).dict(exclude_unset=True) for child in self.hierarchy_definition["children"] if "catalog_id" in child ] diff --git a/stac_fastapi/testdata/joplin/hierarchy.json b/stac_fastapi/testdata/joplin/hierarchy.json index 1d99c54ef..8f77477b0 100644 --- a/stac_fastapi/testdata/joplin/hierarchy.json +++ b/stac_fastapi/testdata/joplin/hierarchy.json @@ -13,6 +13,10 @@ ["joplin", "f7f164c9-cfdf-436d-a3f0-69864c38ba2a"], ["joplin", "f734401c-2df0-4694-a353-cdd3ea760cdc"] ] + }, { + "collection_id": "joplin", + "children": [], + "items": [] }], "items": [ ["joplin", "fe916452-ba6f-4631-9154-c249924a122d"], diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 2b4c63189..80d2c2cba 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -20,8 +20,8 @@ from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.hierarchy import ( BrowseableNode, - browseable_catalog, browseable_catalog_link, + browseable_catalog_page, browseable_collection_link, browseable_item_link, ) @@ -377,16 +377,16 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: ) # Add Collections links - collections = self.all_collections(request=kwargs["request"]) - for collection in collections["collections"]: - landing_page["links"].append( - { - "rel": Relations.child.value, - "type": MimeTypes.json.value, - "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), - } - ) + # 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: @@ -563,8 +563,18 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browseable_catalog(remaining_hierarchy, base_url, catalog_path).dict( - exclude_unset=True + + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + + return browseable_catalog_page( + remaining_hierarchy, + base_url, + catalog_path, + self.stac_version, + self.conformance_classes(), + extension_schemas, ) @abc.abstractmethod @@ -638,17 +648,17 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: 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 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: @@ -669,7 +679,9 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) if "catalog_id" in child: landing_page["links"].append( - browseable_catalog_link(child, base_url, child["catalog_id"]) + 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)) @@ -826,8 +838,18 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: for node in remaining_hierarchy["children"] if node["catalog_id"] == fork ) - return browseable_catalog(remaining_hierarchy, base_url, catalog_path).dict( - exclude_unset=True + + extension_schemas = [ + schema.schema_href for schema in self.extensions if schema.schema_href + ] + + return browseable_catalog_page( + remaining_hierarchy, + base_url, + catalog_path, + self.stac_version, + self.conformance_classes(), + extension_schemas, ) @abc.abstractmethod diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 0858cdabd..8788472ee 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -9,13 +9,12 @@ from typing import List, Optional, Tuple, TypedDict, Union from urllib.parse import urljoin -from stac_pydantic import Catalog 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 ItemPath = Tuple[str, str] -NodeType = str class BrowseableNode(TypedDict): @@ -57,7 +56,7 @@ def browseable_collection_link(node: BrowseableNode, base_url: str) -> str: "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']}"]), + "href": urljoin(base_url, f"collections/{node['collection_id']}"), } @@ -70,22 +69,31 @@ def browseable_item_link(item_path: ItemPath, base_url: str): } -def browseable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> Catalog: - """Generate a catalog based on a CatalogNode in a BrowseableNode tree.""" +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 node["children"] + for child in catalog_node["children"] if "catalog_id" in child ] collection_links = [ browseable_collection_link(child, base_url) - for child in node["children"] + 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 node["items"]] + 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: @@ -96,26 +104,59 @@ def browseable_catalog(node: CatalogNode, base_url: str, catalog_path: str) -> C 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": Relations.self.value, + "rel": "data", "type": MimeTypes.json, - "href": urljoin(base_url, f"/catalogs/{catalog_path.strip('/')}"), + "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}, ] - return Catalog( + + catalog_page = stac_types.LandingPage( type="Catalog", - id=node["catalog_id"], - description=node.get("description") - or f"Generated description for {node['catalog_id']}", - stac_version=STAC_VERSION, - links=children_links + item_links + standard_links, + 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: From dadce7deb8f629a3dd1138acb172ffca006a0175 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Wed, 26 Jan 2022 17:17:34 -0600 Subject: [PATCH 10/21] Add support for sub-catalog behaviors --- stac_fastapi/api/stac_fastapi/api/app.py | 145 ++++++- stac_fastapi/api/stac_fastapi/api/models.py | 12 +- .../pgstac/stac_fastapi/pgstac/app.py | 1 + stac_fastapi/types/stac_fastapi/types/core.py | 359 ++++++++++++++++-- 4 files changed, 453 insertions(+), 64 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 75bd42e48..c8659d322 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -85,9 +85,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 ) @@ -268,25 +274,6 @@ def register_get_collection(self): ), ) - 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_get_root_children(self): """Register get collection children endpoint (GET /collection/{collection_id}/children). @@ -333,6 +320,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. @@ -357,9 +457,14 @@ def register_core(self): self.register_get_search() self.register_get_collections() self.register_get_collection() - self.register_get_catalog() 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() + self.register_get_catalog() if self.settings.browseable_hierarchy_definition is not None: self.register_get_root_children() diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 8aa17d7aa..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,25 +74,27 @@ 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", ) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py index 8dbf19673..c7d91e054 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py @@ -47,6 +47,7 @@ ), 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/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 80d2c2cba..485d7d87c 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,11 +1,13 @@ """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 Request +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_pydantic.version import STAC_VERSION @@ -377,16 +379,16 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: ) # Add Collections links - # collections = self.all_collections(request=kwargs["request"]) - # for collection in collections["collections"]: - # landing_page["links"].append( - # { - # "rel": Relations.child.value, - # "type": MimeTypes.json.value, - # "title": collection.get("title") or collection.get("id"), - # "href": urljoin(base_url, f"collections/{collection['id']}"), - # } - # ) + 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: @@ -410,9 +412,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: ) if "catalog_id" in child: landing_page["links"].append( - browseable_catalog_link( - child, urljoin(base_url, "catalogs"), "" - ) + 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)) @@ -542,6 +542,144 @@ def get_root_children(self, **kwargs) -> stac_types.Children: """ ... + 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() + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") + child_collections = [ + node["collection_id"] + for node in remaining_hierarchy["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("/") + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog 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 remaining_hierarchy["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("/") + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") + child_collections = [ + self.get_collection(node["collection_id"], **kwargs) + for node in remaining_hierarchy["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. @@ -556,13 +694,16 @@ def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: request: Request = kwargs["request"] base_url = str(request.base_url) split_path = catalog_path.split("/") - remaining_hierarchy = self.hierarchy_definition + remaining_hierarchy = self.hierarchy_definition.copy() for fork in split_path: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if node["catalog_id"] == fork - ) + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") extension_schemas = [ schema.schema_href for schema in self.extensions if schema.schema_href @@ -648,17 +789,17 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: 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 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: @@ -679,9 +820,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) if "catalog_id" in child: landing_page["links"].append( - browseable_catalog_link( - child, base_url, child["catalog_id"], "" - ) + 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)) @@ -817,6 +956,145 @@ async def get_root_children( """ ... + 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() + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") + child_collections = [ + node["collection_id"] + for node in remaining_hierarchy["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. + """ + remaining_hierarchy = self.hierarchy_definition.copy() + split_path = catalog_path.split("/") + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog 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 remaining_hierarchy["children"] + if "collection_id" in node + ] + 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("/") + for fork in split_path: + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") + child_collections_io = [ + self.get_collection(node["collection_id"], **kwargs) + for node in remaining_hierarchy["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. @@ -833,11 +1111,14 @@ async def get_catalog(self, catalog_path: str, **kwargs) -> stac_types.Catalog: split_path = catalog_path.split("/") remaining_hierarchy = self.hierarchy_definition for fork in split_path: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if node["catalog_id"] == fork - ) + try: + remaining_hierarchy = next( + node + for node in remaining_hierarchy["children"] + if "catalog_id" in node and node["catalog_id"] == fork + ) + except StopIteration: + raise HTTPException(status_code=404, detail="Catalog not found") extension_schemas = [ schema.schema_href for schema in self.extensions if schema.schema_href From c1f6b3c95ed98b8705c4b735db00df80181e4400 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 1 Feb 2022 10:27:08 -0600 Subject: [PATCH 11/21] Break clients out for more organization and smaller files --- stac_fastapi/api/stac_fastapi/api/app.py | 25 +- .../extensions/core/filter/filter.py | 2 +- .../extensions/core/transaction.py | 5 +- .../pgstac/stac_fastapi/pgstac/core.py | 2 +- .../stac_fastapi/pgstac/transactions.py | 2 +- .../stac_fastapi/sqlalchemy/core.py | 60 +- .../stac_fastapi/sqlalchemy/transactions.py | 2 +- .../sqlalchemy/tests/resources/test_item.py | 2 +- .../stac_fastapi/types/clients/__init__.py | 1 + .../stac_fastapi/types/clients/async_core.py | 524 ++++++++++++++++++ .../stac_fastapi/types/clients/filter.py | 59 ++ .../stac_fastapi/types/clients/hierarchy.py | 1 + .../stac_fastapi/types/clients/landing.py | 75 +++ .../stac_fastapi/types/clients/sync_core.py | 475 ++++++++++++++++ .../stac_fastapi/types/clients/transaction.py | 210 +++++++ .../types/stac_fastapi/types/hierarchy.py | 16 + 16 files changed, 1396 insertions(+), 65 deletions(-) create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/__init__.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/async_core.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/filter.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/landing.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/sync_core.py create mode 100644 stac_fastapi/types/stac_fastapi/types/clients/transaction.py diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index c8659d322..6df0ac1c6 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -34,8 +34,9 @@ # 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 @@ -275,7 +276,7 @@ def register_get_collection(self): ) def register_get_root_children(self): - """Register get collection children endpoint (GET /collection/{collection_id}/children). + """Register get collection children endpoint (GET /children). Returns: None @@ -293,6 +294,25 @@ def register_get_root_children(self): ), ) + 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_root_children, CatalogUri, self.response_class + ), + ) + def register_get_item_collection(self): """Register get item collection endpoint (GET /collection/{collection_id}/items). @@ -467,6 +487,7 @@ def register_core(self): self.register_get_catalog() if self.settings.browseable_hierarchy_definition is not None: self.register_get_root_children() + self.register_get_catalog_children() def customize_openapi(self) -> Optional[Dict[str, Any]]: """Customize openapi schema.""" 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/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 476301fc9..0023240e1 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 diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py index d42193bd7..47bd2cc4e 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/core.py @@ -17,7 +17,7 @@ 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.hierarchy import browseable_catalog_page from stac_fastapi.types.stac import ( 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/core.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py index 462cf2f3d..1b1184ad5 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py @@ -10,7 +10,7 @@ import geoalchemy2 as ga import sqlalchemy as sa import stac_pydantic -from fastapi import HTTPException, Request +from fastapi import HTTPException from pydantic import ValidationError from shapely.geometry import Polygon as ShapelyPolygon from shapely.geometry import shape @@ -25,18 +25,11 @@ 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.hierarchy import browseable_catalog_page from stac_fastapi.types.search import BaseSearchPostRequest -from stac_fastapi.types.stac import ( - Children, - Collection, - Collections, - Item, - ItemCollection, -) +from stac_fastapi.types.stac import Collection, Collections, Item, ItemCollection logger = logging.getLogger(__name__) @@ -105,53 +98,6 @@ def get_collection(self, collection_id: str, **kwargs) -> Collection: collection = self._lookup_id(collection_id, self.collection_table, session) return self.collection_serializer.db_to_stac(collection, base_url) - def get_root_children(self, **kwargs) -> Children: - """Get children of 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 - ] - catalog_children = [ - browseable_catalog_page( - child, - base_url, - child["catalog_id"], - self.stac_version, - self.conformance_classes(), - extension_schemas, - ).dict(exclude_unset=True) - for child in self.hierarchy_definition["children"] - if "catalog_id" in child - ] - 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 Children( - children=catalog_children + collection_children["collections"], links=links - ) - def item_collection( self, collection_id: str, limit: int = 10, token: str = None, **kwargs ) -> 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..4e6b94f7d 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 import LandingPageMixin from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime 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..5f5abbb7e --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -0,0 +1,524 @@ +"""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() + + 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": "children", # todo: add this relation to stac-pydantic + "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, collection_id: str, **kwargs + ) -> stac_types.Children: + """Get children by parent's collection id. + + Called with `GET /children`. + + Args: + collection_id: Id of the collection. + + 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, + ).dict(exclude_unset=True) + 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 stac_types.Children( + children=catalog_children + collection_children["collections"], 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. + """ + 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 + ] + 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/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py new file mode 100644 index 000000000..9cc385625 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py @@ -0,0 +1 @@ +"""Base clients.""" 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..ab1b86e97 --- /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.json, + "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..001e754c4 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -0,0 +1,475 @@ +"""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": "children", # todo: add this relation to stac-pydantic + "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 by parent collection id. + + Called with `GET /collections/{collection_id}/children`. + + Args: + collection_id: Id of the collection. + + 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, + ).dict(exclude_unset=True) + for child in self.hierarchy_definition["children"] + if "catalog_id" in child + ] + 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 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/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 8788472ee..c2b8a9101 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -38,6 +38,22 @@ class CatalogNode(BrowseableNode): 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: From 316661cfad8ab7038c8355a3b8abc5cb0c73f85d Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 1 Feb 2022 10:50:04 -0600 Subject: [PATCH 12/21] Add todos for pgstac --- .../types/stac_fastapi/types/clients/async_core.py | 10 +++++++--- .../types/stac_fastapi/types/clients/hierarchy.py | 1 - stac_fastapi/types/stac_fastapi/types/hierarchy.py | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) delete mode 100644 stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py index 5f5abbb7e..8eccd036e 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -53,6 +53,7 @@ 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) @@ -99,6 +100,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) # Add links for children and browseable conformance + # PGSTAC TODO: generate links for collections and catalogs to avoid gathering all collections if self.hierarchy_definition is not None: # Children landing_page["links"].append( @@ -237,9 +239,7 @@ async def get_collection( """ ... - async def get_root_children( - self, collection_id: str, **kwargs - ) -> stac_types.Children: + async def get_root_children(self, **kwargs) -> stac_types.Children: """Get children by parent's collection id. Called with `GET /children`. @@ -307,6 +307,8 @@ async def post_catalog_search( request_path = kwargs["request"]["path"] split_path = request_path.split("/")[2:-1] remaining_hierarchy = self.hierarchy_definition.copy() + # PGSTAC TODO: Add optional 'catalog' parameter to search, which will determine which + # collections will be included in search selected_catalog = find_catalog(remaining_hierarchy, split_path) if not selected_catalog: raise HTTPException( @@ -370,6 +372,8 @@ async def get_catalog_search( **kwargs, ) + # PGSTAC TODO: add function for assembling all collection children of a catalog + # (all children simpliciter?) async def get_catalog_collections( self, catalog_path: str, **kwargs ) -> stac_types.Collections: diff --git a/stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py deleted file mode 100644 index 9cc385625..000000000 --- a/stac_fastapi/types/stac_fastapi/types/clients/hierarchy.py +++ /dev/null @@ -1 +0,0 @@ -"""Base clients.""" diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index c2b8a9101..a76d78315 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -38,6 +38,8 @@ class CatalogNode(BrowseableNode): description: Optional[str] +# PGSTAC TODO: function to look up catalog (could be as simple as selecting +# 'where catalog_id = full/catalog/path' which implementers will have to sort out) def find_catalog( hierarchy: BrowseableNode, split_path: List[str] ) -> Optional[BrowseableNode]: @@ -175,6 +177,7 @@ def browseable_catalog_page( return catalog_page +# PGSTAC TODO: function to allow ingest of hierarchy def parse_hierarchy(d: dict) -> BrowseableNode: """Parse a dictionary as a BrowseableNode tree.""" d_items = d.get("items") or [] From 391a732c5f480619c298bc1211c0e58e002e75a2 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Wed, 2 Feb 2022 14:48:08 -0600 Subject: [PATCH 13/21] Add /catalogs/{catalog}/children endpoint --- stac_fastapi/api/stac_fastapi/api/app.py | 4 +- .../stac_fastapi/types/clients/async_core.py | 78 +++++++++++++++++-- .../stac_fastapi/types/clients/sync_core.py | 73 +++++++++++++++-- 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 6df0ac1c6..4f32a15a8 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -309,7 +309,7 @@ def register_get_catalog_children(self): response_model_exclude_none=True, methods=["GET"], endpoint=self._create_endpoint( - self.client.get_root_children, CatalogUri, self.response_class + self.client.get_catalog_children, CatalogUri, self.response_class ), ) @@ -484,10 +484,10 @@ def register_core(self): self.register_post_catalog_search() self.register_get_catalog_search() self.register_get_catalog_collections() - self.register_get_catalog() 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.""" diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py index 8eccd036e..b9aa63ad9 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -240,13 +240,10 @@ async def get_collection( ... async def get_root_children(self, **kwargs) -> stac_types.Children: - """Get children by parent's collection id. + """Get children at root. Called with `GET /children`. - Args: - collection_id: Id of the collection. - Returns: Children. """ @@ -263,7 +260,7 @@ async def get_root_children(self, **kwargs) -> stac_types.Children: self.stac_version, self.conformance_classes(), extension_schemas, - ).dict(exclude_unset=True) + ) for child in self.hierarchy_definition["children"] if "catalog_id" in child ] @@ -289,6 +286,73 @@ async def get_root_children(self, **kwargs) -> 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: @@ -343,9 +407,9 @@ async def get_catalog_search( Returns: ItemCollection containing items which match the search criteria. """ - remaining_hierarchy = self.hierarchy_definition.copy() + hierarchy = self.hierarchy_definition.copy() split_path = catalog_path.split("/") - selected_catalog = find_catalog(remaining_hierarchy, split_path) + 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" diff --git a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py index 001e754c4..3c97f722e 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -247,12 +247,9 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: ... def get_root_children(self, **kwargs) -> stac_types.Children: - """Get children by parent collection id. + """Get children at root. - Called with `GET /collections/{collection_id}/children`. - - Args: - collection_id: Id of the collection. + Called with `GET /children`. Returns: Children. @@ -270,7 +267,7 @@ def get_root_children(self, **kwargs) -> stac_types.Children: self.stac_version, self.conformance_classes(), extension_schemas, - ).dict(exclude_unset=True) + ) for child in self.hierarchy_definition["children"] if "catalog_id" in child ] @@ -296,6 +293,70 @@ def get_root_children(self, **kwargs) -> 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: From 7c049f526cf7f9994732c4af57bbf7d229f8aaa1 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 8 Feb 2022 09:09:10 -0600 Subject: [PATCH 14/21] Bump conformance classes to rc1 --- .../extensions/core/filter/filter.py | 30 +++++++++---------- .../stac_fastapi/extensions/core/sort/sort.py | 2 +- .../extensions/core/transaction.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) 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 356555cbb..581dcb94d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -18,25 +18,25 @@ class FilterConformanceClasses(str, Enum): """Conformance classes for the Filter extension. - See https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-rc.1/fragments/filter + See https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-beta.5/fragments/filter """ - FILTER = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:filter" + FILTER = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:filter" ITEM_SEARCH_FILTER = ( - "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:item-search-filter" + "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:item-search-filter" ) - CQL_TEXT = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:cql-text" - CQL_JSON = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:cql-json" - BASIC_CQL = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-cql" - BASIC_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-spatial-operators" - BASIC_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-temporal-operators" - ENHANCED_COMPARISON_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-comparison-operators" - ENHANCED_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-spatial-operators" - ENHANCED_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-temporal-operators" - FUNCTIONS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:functions" - ARITHMETIC = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:arithmetic" - ARRAYS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:arrays" - QUERYABLE_SECOND_OPERAND = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:queryable-second-operand" + CQL_TEXT = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:cql-text" + CQL_JSON = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:cql-json" + BASIC_CQL = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-cql" + BASIC_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-spatial-operators" + BASIC_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-temporal-operators" + ENHANCED_COMPARISON_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-comparison-operators" + ENHANCED_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-spatial-operators" + ENHANCED_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-temporal-operators" + FUNCTIONS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:functions" + ARITHMETIC = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:arithmetic" + ARRAYS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:arrays" + QUERYABLE_SECOND_OPERAND = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:queryable-second-operand" @attr.s 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..7ffc2d89c 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-beta.5/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 0023240e1..d18d2742d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -41,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-beta.5/ogcapi-features/extensions/transaction/", "http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/simpletx", ] ) From dd13bff5c0013711259638db1f772d8b61758343 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Thu, 14 Apr 2022 12:50:29 -0500 Subject: [PATCH 15/21] Fix import after rebase --- stac_fastapi/sqlalchemy/tests/resources/test_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/sqlalchemy/tests/resources/test_item.py b/stac_fastapi/sqlalchemy/tests/resources/test_item.py index 4e6b94f7d..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.clients import LandingPageMixin +from stac_fastapi.types.clients.landing import LandingPageMixin from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime From 8baf3247a2ad700ea66a36ee4f0b39dc2bc30ee0 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 19 Apr 2022 09:27:13 -0500 Subject: [PATCH 16/21] Set mimetype on search link to geojson --- stac_fastapi/types/stac_fastapi/types/clients/landing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/types/stac_fastapi/types/clients/landing.py b/stac_fastapi/types/stac_fastapi/types/clients/landing.py index ab1b86e97..781e0defd 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/landing.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/landing.py @@ -64,7 +64,7 @@ def _landing_page( }, { "rel": Relations.search.value, - "type": MimeTypes.json, + "type": MimeTypes.geojson, "title": "STAC search", "href": urljoin(base_url, "search"), "method": "POST", From 591fc278b823981645388a8350f222ebe3525b0b Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 19 Apr 2022 09:53:34 -0500 Subject: [PATCH 17/21] Remove duplicate introduced via rebase --- CHANGES.md | 2 + Makefile | 9 +- stac_fastapi/api/tests/test_api.py | 11 +- stac_fastapi/types/stac_fastapi/types/core.py | 1206 ----------------- 4 files changed, 17 insertions(+), 1211 deletions(-) delete mode 100644 stac_fastapi/types/stac_fastapi/types/core.py 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/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/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py deleted file mode 100644 index 485d7d87c..000000000 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ /dev/null @@ -1,1206 +0,0 @@ -"""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_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, - 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, -) -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) - 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": "children", # todo: add this relation to stac-pydantic - "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: 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 get_root_children(self, **kwargs) -> stac_types.Children: - """Get children by parent collection id. - - Called with `GET /collections/{collection_id}/children`. - - Args: - collection_id: Id of the collection. - - Returns: - Children. - """ - ... - - 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() - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - child_collections = [ - node["collection_id"] - for node in remaining_hierarchy["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("/") - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog 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 remaining_hierarchy["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("/") - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - child_collections = [ - self.get_collection(node["collection_id"], **kwargs) - for node in remaining_hierarchy["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() - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - - extension_schemas = [ - schema.schema_href for schema in self.extensions if schema.schema_href - ] - - return browseable_catalog_page( - remaining_hierarchy, - 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. - """ - ... - - -@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() - - 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": "children", # todo: add this relation to stac-pydantic - "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: 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 get_root_children( - self, collection_id: str, **kwargs - ) -> stac_types.Children: - """Get children by parent's collection id. - - Called with `GET /children`. - - Args: - collection_id: Id of the collection. - - Returns: - Children. - """ - ... - - 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() - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - child_collections = [ - node["collection_id"] - for node in remaining_hierarchy["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. - """ - remaining_hierarchy = self.hierarchy_definition.copy() - split_path = catalog_path.split("/") - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog 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 remaining_hierarchy["children"] - if "collection_id" in node - ] - 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("/") - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - child_collections_io = [ - self.get_collection(node["collection_id"], **kwargs) - for node in remaining_hierarchy["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 - for fork in split_path: - try: - remaining_hierarchy = next( - node - for node in remaining_hierarchy["children"] - if "catalog_id" in node and node["catalog_id"] == fork - ) - except StopIteration: - raise HTTPException(status_code=404, detail="Catalog not found") - - extension_schemas = [ - schema.schema_href for schema in self.extensions if schema.schema_href - ] - - return browseable_catalog_page( - remaining_hierarchy, - 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": {}, - } From b9082693befbb57edaf3dc4fb39155c22c676923 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 19 Apr 2022 10:20:04 -0500 Subject: [PATCH 18/21] Bump version to rc1 --- .../extensions/core/filter/filter.py | 30 +++++++++---------- .../stac_fastapi/extensions/core/sort/sort.py | 2 +- .../extensions/core/transaction.py | 2 +- .../types/stac_fastapi/types/conformance.py | 4 +-- 4 files changed, 19 insertions(+), 19 deletions(-) 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 581dcb94d..356555cbb 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -18,25 +18,25 @@ class FilterConformanceClasses(str, Enum): """Conformance classes for the Filter extension. - See https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-beta.5/fragments/filter + See https://github.com/radiantearth/stac-api-spec/tree/v1.0.0-rc.1/fragments/filter """ - FILTER = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:filter" + FILTER = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:filter" ITEM_SEARCH_FILTER = ( - "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:item-search-filter" + "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:item-search-filter" ) - CQL_TEXT = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:cql-text" - CQL_JSON = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:cql-json" - BASIC_CQL = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-cql" - BASIC_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-spatial-operators" - BASIC_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:basic-temporal-operators" - ENHANCED_COMPARISON_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-comparison-operators" - ENHANCED_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-spatial-operators" - ENHANCED_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:enhanced-temporal-operators" - FUNCTIONS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:functions" - ARITHMETIC = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:arithmetic" - ARRAYS = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:arrays" - QUERYABLE_SECOND_OPERAND = "https://api.stacspec.org/v1.0.0-beta.5/item-search#filter:queryable-second-operand" + CQL_TEXT = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:cql-text" + CQL_JSON = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:cql-json" + BASIC_CQL = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-cql" + BASIC_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-spatial-operators" + BASIC_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:basic-temporal-operators" + ENHANCED_COMPARISON_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-comparison-operators" + ENHANCED_SPATIAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-spatial-operators" + ENHANCED_TEMPORAL_OPERATORS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:enhanced-temporal-operators" + FUNCTIONS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:functions" + ARITHMETIC = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:arithmetic" + ARRAYS = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:arrays" + QUERYABLE_SECOND_OPERAND = "https://api.stacspec.org/v1.0.0-rc.1/item-search#filter:queryable-second-operand" @attr.s 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 7ffc2d89c..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-beta.5/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 d18d2742d..89fcb6e03 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -41,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-beta.5/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/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 3253d2f20..352c6ea82 100644 --- a/stac_fastapi/types/stac_fastapi/types/conformance.py +++ b/stac_fastapi/types/stac_fastapi/types/conformance.py @@ -29,6 +29,6 @@ class OAFConformanceClasses(str, Enum): OAFConformanceClasses.GEOJSON, ] -CHILDREN_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-beta.5/children" +CHILDREN_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-rc.1/children" -BROWSEABLE_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-beta.5/browseable" +BROWSEABLE_CONFORMANCE_CLASS = "https://api.stacspec.org/v1.0.0-rc.1/browseable" From 242e1fef87f2909096500abf5e9d222ec209016e Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Mon, 25 Apr 2022 10:57:00 -0500 Subject: [PATCH 19/21] Respond to review --- stac_fastapi/pgstac/stac_fastapi/pgstac/app.py | 8 ++------ .../sqlalchemy/stac_fastapi/sqlalchemy/app.py | 8 ++------ .../types/stac_fastapi/types/clients/async_core.py | 7 +------ .../types/stac_fastapi/types/clients/sync_core.py | 2 +- stac_fastapi/types/stac_fastapi/types/hierarchy.py | 11 ++++++++--- 5 files changed, 14 insertions(+), 22 deletions(-) diff --git a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py index c7d91e054..94108789b 100644 --- a/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py +++ b/stac_fastapi/pgstac/stac_fastapi/pgstac/app.py @@ -1,6 +1,4 @@ """FastAPI application using PGStac.""" -import json - from fastapi.responses import ORJSONResponse from stac_fastapi.api.app import StacApi @@ -18,7 +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 BrowseableNode, parse_hierarchy +from stac_fastapi.types.hierarchy import parse_hierarchy_file settings = Settings() extensions = [ @@ -33,9 +31,7 @@ TokenPaginationExtension(), ContextExtension(), ] -with open(settings.browseable_hierarchy_definition, "r") as definition_file: - hierarchy_json = json.load(definition_file) - hierarchy_definition: BrowseableNode = parse_hierarchy(hierarchy_json) +hierarchy_definition = parse_hierarchy_file(settings.browseable_hierarchy_definition) post_request_model = create_post_request_model(extensions, base_model=PgstacSearch) diff --git a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py index 80628b928..9aa6d50e8 100644 --- a/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py +++ b/stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/app.py @@ -1,6 +1,4 @@ """FastAPI application.""" -import json - from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import create_get_request_model, create_post_request_model from stac_fastapi.extensions.core import ( @@ -19,7 +17,7 @@ BulkTransactionsClient, TransactionsClient, ) -from stac_fastapi.types.hierarchy import BrowseableNode, parse_hierarchy +from stac_fastapi.types.hierarchy import parse_hierarchy_file settings = SqlalchemySettings() session = Session.create_from_settings(settings) @@ -32,9 +30,7 @@ TokenPaginationExtension(), ContextExtension(), ] -with open(settings.browseable_hierarchy_definition, "r") as definition_file: - hierarchy_json = json.load(definition_file) - hierarchy_definition: BrowseableNode = parse_hierarchy(hierarchy_json) +hierarchy_definition = parse_hierarchy_file(settings.browseable_hierarchy_definition) post_request_model = create_post_request_model(extensions) diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py index b9aa63ad9..7536142b2 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -100,12 +100,11 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) # Add links for children and browseable conformance - # PGSTAC TODO: generate links for collections and catalogs to avoid gathering all collections if self.hierarchy_definition is not None: # Children landing_page["links"].append( { - "rel": "children", # todo: add this relation to stac-pydantic + "rel": "children", # todo: https://github.com/stac-utils/stac-pydantic/pull/112 "type": MimeTypes.json.value, "title": "Child collections and catalogs", "href": urljoin(base_url, "children"), @@ -371,8 +370,6 @@ async def post_catalog_search( request_path = kwargs["request"]["path"] split_path = request_path.split("/")[2:-1] remaining_hierarchy = self.hierarchy_definition.copy() - # PGSTAC TODO: Add optional 'catalog' parameter to search, which will determine which - # collections will be included in search selected_catalog = find_catalog(remaining_hierarchy, split_path) if not selected_catalog: raise HTTPException( @@ -436,8 +433,6 @@ async def get_catalog_search( **kwargs, ) - # PGSTAC TODO: add function for assembling all collection children of a catalog - # (all children simpliciter?) async def get_catalog_collections( self, catalog_path: str, **kwargs ) -> stac_types.Collections: diff --git a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py index 3c97f722e..f04c27d5c 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -113,7 +113,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Children landing_page["links"].append( { - "rel": "children", # todo: add this relation to stac-pydantic + "rel": "children", # todo: https://github.com/stac-utils/stac-pydantic/pull/112 "type": MimeTypes.json.value, "title": "Child collections and catalogs", "href": urljoin(base_url, "children"), diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index a76d78315..6ed2a42c4 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -6,6 +6,7 @@ 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 @@ -38,8 +39,6 @@ class CatalogNode(BrowseableNode): description: Optional[str] -# PGSTAC TODO: function to look up catalog (could be as simple as selecting -# 'where catalog_id = full/catalog/path' which implementers will have to sort out) def find_catalog( hierarchy: BrowseableNode, split_path: List[str] ) -> Optional[BrowseableNode]: @@ -177,7 +176,6 @@ def browseable_catalog_page( return catalog_page -# PGSTAC TODO: function to allow ingest of hierarchy def parse_hierarchy(d: dict) -> BrowseableNode: """Parse a dictionary as a BrowseableNode tree.""" d_items = d.get("items") or [] @@ -198,3 +196,10 @@ def parse_hierarchy(d: dict) -> BrowseableNode: ) else: return BrowseableNode(children=d_children, items=d_items) + + +def parse_hierarchy_file(hierarchy_file_path: str) -> BrowseableNode: + """Parse contents of a file as a BrowseableNode tree.""" + with open(hierarchy_file_path, "r") as definition_file: + hierarchy_json = json.load(definition_file) + return parse_hierarchy(hierarchy_json) From 34af87206fd81bd62922e773dff7cd78f7d68ccc Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Mon, 2 May 2022 13:16:35 -0500 Subject: [PATCH 20/21] Avoid error when no hierarchy file is specified --- .../stac_fastapi/types/clients/async_core.py | 27 ++++++++++--------- .../stac_fastapi/types/clients/sync_core.py | 27 ++++++++++--------- .../types/stac_fastapi/types/hierarchy.py | 13 ++++++--- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py index 7536142b2..7c815e94d 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -251,18 +251,21 @@ async def get_root_children(self, **kwargs) -> stac_types.Children: 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 - ] + 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 = [ { diff --git a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py index f04c27d5c..c7bb10632 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -259,18 +259,21 @@ def get_root_children(self, **kwargs) -> stac_types.Children: 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 - ] + 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 = [ { diff --git a/stac_fastapi/types/stac_fastapi/types/hierarchy.py b/stac_fastapi/types/stac_fastapi/types/hierarchy.py index 6ed2a42c4..c84a57006 100644 --- a/stac_fastapi/types/stac_fastapi/types/hierarchy.py +++ b/stac_fastapi/types/stac_fastapi/types/hierarchy.py @@ -198,8 +198,13 @@ def parse_hierarchy(d: dict) -> BrowseableNode: return BrowseableNode(children=d_children, items=d_items) -def parse_hierarchy_file(hierarchy_file_path: str) -> BrowseableNode: +def parse_hierarchy_file( + hierarchy_file_path: Optional[str], +) -> Optional[BrowseableNode]: """Parse contents of a file as a BrowseableNode tree.""" - with open(hierarchy_file_path, "r") as definition_file: - hierarchy_json = json.load(definition_file) - return parse_hierarchy(hierarchy_json) + 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 From 42f5e282e92c014adccb7ae4905659c20b72d687 Mon Sep 17 00:00:00 2001 From: Nathan Zimmerman Date: Tue, 3 May 2022 15:35:35 -0500 Subject: [PATCH 21/21] Use new relations from stac-pydantic 2.0.3 --- stac_fastapi/types/stac_fastapi/types/clients/async_core.py | 2 +- stac_fastapi/types/stac_fastapi/types/clients/sync_core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py index 7c815e94d..5b5d84718 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/async_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/async_core.py @@ -104,7 +104,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: # Children landing_page["links"].append( { - "rel": "children", # todo: https://github.com/stac-utils/stac-pydantic/pull/112 + "rel": Relations.children, "type": MimeTypes.json.value, "title": "Child collections and catalogs", "href": urljoin(base_url, "children"), diff --git a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py index c7bb10632..cb8c41bd1 100644 --- a/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py +++ b/stac_fastapi/types/stac_fastapi/types/clients/sync_core.py @@ -113,7 +113,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: # Children landing_page["links"].append( { - "rel": "children", # todo: https://github.com/stac-utils/stac-pydantic/pull/112 + "rel": Relations.children, "type": MimeTypes.json.value, "title": "Child collections and catalogs", "href": urljoin(base_url, "children"),