From 4cd86391781e0e30b0fff57842fa9e361eca78ce Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 31 Oct 2023 20:41:14 +0100 Subject: [PATCH] add items/item depencencies --- CHANGES.md | 3 + docs/src/user_guide/factories.md | 28 +++++- tipg/dependencies.py | 159 ++++++++++++++++++++++++++++- tipg/factory.py | 165 +++++-------------------------- 4 files changed, 205 insertions(+), 150 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 777d6e11..85f2c037 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,8 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin prev: Optional[int] ``` +- add `tipg.dependencies.ItemsParams` and `tipg.dependencies.ItemParams` + ### fixed - hide map element in HTML pages when collections/items do not have spatial component (https://github.com/developmentseed/tipg/issues/132) @@ -93,6 +95,7 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin - move `s_intersects` and `t_intersects` functions from `tipg.factory` to `tipg.dependencies` +- add `items_dependency` and `item_dependency` attributes in `OGCFeaturesFactory` class. Those new dependencies define the input for the `/items` and `/items/{itemId}` endpoints ## [0.4.4] - 2023-10-03 diff --git a/docs/src/user_guide/factories.md b/docs/src/user_guide/factories.md index 95722f43..86966069 100644 --- a/docs/src/user_guide/factories.md +++ b/docs/src/user_guide/factories.md @@ -7,10 +7,21 @@ class Factory: collections_dependency: Callable collection_dependency: Callable - - def __init__(self, collections_dependency: Callable, collection_dependency: Callable): + items_dependency: Callable + item_dependency: Callable + + def __init__( + self, + collections_dependency: Callable, + collection_dependency: Callable, + items_dependency: Callable, + item_dependency: Callable, + ): self.collections_dependency = collections_dependency self.collection_dependency = collection_dependency + self.items_dependency = items_dependency + self.item_dependency = item_dependency + self.router = APIRouter() self.register_routes() @@ -35,17 +46,16 @@ class Factory: def items( request: Request, collection=Depends(self.collection_dependency), + item_list=Depends(self.items_dependency), ): - item_list = collection.features(...) ... @self.router.get("/collections/{collectionId}/items/{itemId}") def item( request: Request, collection=Depends(self.collection_dependency), - itemId: str = Path(..., description="Item identifier"), + feature=Depends(self.item_dependency) ): - item_list = collection.features(ids_filter=[itemId]) ... @@ -76,6 +86,10 @@ app.include_router(endpoints.router, tags=["OGC Features API"]) - **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance +- **items_dependency** (Callable[..., tipg.collections.ItemList]): Callable which return a ItemList dictionary + +- **item_dependency** (Callable[..., tipg.collections.Feature]): Callable which return a Feature dictionary + - **with_common** (bool, optional): Create Full OGC Features API set of endpoints with OGC Common endpoints (landing `/` and conformance `/conformance`). Defaults to `True` - **router** (fastapi.APIRouter, optional): FastAPI @@ -158,6 +172,10 @@ app.include_router(endpoints.router) - **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance +- **items_dependency** (Callable[..., tipg.collections.ItemList]): Callable which return a ItemList dictionary + +- **item_dependency** (Callable[..., tipg.collections.Feature]): Callable which return a Feature dictionary + - **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents) - **with_tiles_viewer** (bool, optional): add `/viewer` endpoint to visualize the Vector tile. Defaults to `True` diff --git a/tipg/dependencies.py b/tipg/dependencies.py index c8d7b676..7a288965 100644 --- a/tipg/dependencies.py +++ b/tipg/dependencies.py @@ -11,16 +11,23 @@ from pygeofilter.parsers.cql2_text import parse as cql2_text_parser from typing_extensions import Annotated -from tipg.collections import Catalog, Collection, CollectionList -from tipg.errors import InvalidBBox, MissingCollectionCatalog, MissingFunctionParameter +from tipg.collections import Catalog, Collection, CollectionList, Feature, ItemList +from tipg.errors import ( + InvalidBBox, + MissingCollectionCatalog, + MissingFunctionParameter, + NoPrimaryKey, + NotFound, +) from tipg.resources.enums import MediaType -from tipg.settings import TMSSettings +from tipg.settings import FeaturesSettings, TMSSettings from fastapi import Depends, HTTPException, Path, Query from starlette.requests import Request tms_settings = TMSSettings() +features_settings = FeaturesSettings() ResponseType = Literal["json", "html"] QueryablesResponseType = Literal["schemajson", "html"] @@ -505,3 +512,149 @@ def CollectionsParams( next=offset + returned if matched - returned > offset else None, prev=max(offset - returned, 0) if offset else None, ) + + +async def ItemsParams( + request: Request, + collection: Annotated[Collection, Depends(CollectionParams)], + ids_filter: Annotated[Optional[List[str]], Depends(ids_query)], + bbox_filter: Annotated[Optional[List[float]], Depends(bbox_query)], + datetime_filter: Annotated[Optional[List[str]], Depends(datetime_query)], + properties: Annotated[Optional[List[str]], Depends(properties_query)], + cql_filter: Annotated[Optional[AstType], Depends(filter_query)], + sortby: Annotated[Optional[str], Depends(sortby_query)], + geom_column: Annotated[ + Optional[str], + Query( + description="Select geometry column.", + alias="geom-column", + ), + ] = None, + datetime_column: Annotated[ + Optional[str], + Query( + description="Select datetime column.", + alias="datetime-column", + ), + ] = None, + limit: Annotated[ + int, + Query( + ge=0, + le=features_settings.max_features_per_query, + description="Limits the number of features in the response.", + ), + ] = features_settings.default_features_limit, + offset: Annotated[ + Optional[int], + Query( + ge=0, + description="Starts the response at an offset.", + ), + ] = None, + bbox_only: Annotated[ + Optional[bool], + Query( + description="Only return the bounding box of the feature.", + alias="bbox-only", + ), + ] = None, + simplify: Annotated[ + Optional[float], + Query( + description="Simplify the output geometry to given threshold in decimal degrees.", + ), + ] = None, + output_type: Annotated[Optional[MediaType], Depends(ItemsOutputType)] = None, +) -> ItemList: + """Get list of Items.""" + output_type = output_type or MediaType.geojson + geom_as_wkt = output_type not in [ + MediaType.geojson, + MediaType.geojsonseq, + MediaType.html, + ] + + item_list = await collection.features( + request.app.state.pool, + ids_filter=ids_filter, + bbox_filter=bbox_filter, + datetime_filter=datetime_filter, + properties_filter=properties_filter_query(request, collection), + function_parameters=function_parameters_query(request, collection), + cql_filter=cql_filter, + sortby=sortby, + properties=properties, + limit=limit, + offset=offset, + geom=geom_column, + dt=datetime_column, + bbox_only=bbox_only, + simplify=simplify, + geom_as_wkt=geom_as_wkt, + ) + + return item_list + + +async def ItemParams( + request: Request, + collection: Annotated[Collection, Depends(CollectionParams)], + itemId: Annotated[str, Path(description="Item identifier")], + bbox_only: Annotated[ + Optional[bool], + Query( + description="Only return the bounding box of the feature.", + alias="bbox-only", + ), + ] = None, + simplify: Annotated[ + Optional[float], + Query( + description="Simplify the output geometry to given threshold in decimal degrees.", + ), + ] = None, + geom_column: Annotated[ + Optional[str], + Query( + description="Select geometry column.", + alias="geom-column", + ), + ] = None, + datetime_column: Annotated[ + Optional[str], + Query( + description="Select datetime column.", + alias="datetime-column", + ), + ] = None, + properties: Optional[List[str]] = Depends(properties_query), + output_type: Annotated[Optional[MediaType], Depends(ItemsOutputType)] = None, +) -> Feature: + """Get Item by id.""" + if collection.id_column is None: + raise NoPrimaryKey("No primary key is set on this table") + + output_type = output_type or MediaType.geojson + geom_as_wkt = output_type not in [ + MediaType.geojson, + MediaType.geojsonseq, + MediaType.html, + ] + + item_list = await collection.features( + pool=request.app.state.pool, + bbox_only=bbox_only, + simplify=simplify, + ids_filter=[itemId], + properties=properties, + function_parameters=function_parameters_query(request, collection), + geom=geom_column, + dt=datetime_column, + geom_as_wkt=geom_as_wkt, + ) + + if not item_list["items"]: + raise NotFound(f"Item {itemId} in Collection {collection.id} does not exist.") + + return item_list["items"][0] diff --git a/tipg/factory.py b/tipg/factory.py index ed3e713b..890be692 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -16,11 +16,13 @@ from typing_extensions import Annotated from tipg import model -from tipg.collections import Collection, CollectionList +from tipg.collections import Collection, CollectionList, Feature, ItemList from tipg.dependencies import ( CollectionParams, CollectionsParams, + ItemParams, ItemsOutputType, + ItemsParams, OutputType, QueryablesOutputType, TileParams, @@ -33,7 +35,7 @@ properties_query, sortby_query, ) -from tipg.errors import MissingGeometryColumn, NoPrimaryKey, NotFound +from tipg.errors import MissingGeometryColumn from tipg.resources.enums import MediaType from tipg.resources.response import GeoJSONResponse, SchemaJSONResponse from tipg.settings import FeaturesSettings, MVTSettings, TMSSettings @@ -334,6 +336,10 @@ class OGCFeaturesFactory(EndpointsFactory): # collections dependency collections_dependency: Callable[..., CollectionList] = CollectionsParams + items_dependency: Callable[..., ItemList] = ItemsParams # type: ignore + + item_dependency: Callable[..., Feature] = ItemParams # type: ignore + @property def conforms_to(self) -> List[str]: """Factory conformances.""" @@ -412,10 +418,7 @@ def _collections_route(self): # noqa: C901 ) def collections( request: Request, - collection_list: Annotated[ - CollectionList, - Depends(self.collections_dependency), - ], + collection_list=Depends(self.collections_dependency), output_type: Annotated[ Optional[MediaType], Depends(OutputType), @@ -533,7 +536,7 @@ def _collection_route(self): ) def collection( request: Request, - collection: Annotated[Collection, Depends(self.collection_dependency)], + collection=Depends(self.collection_dependency), output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): """Metadata for a feature collection.""" @@ -620,7 +623,7 @@ def _queryables_route(self): ) def queryables( request: Request, - collection: Annotated[Collection, Depends(self.collection_dependency)], + collection=Depends(self.collection_dependency), output_type: Annotated[ Optional[MediaType], Depends(QueryablesOutputType) ] = None, @@ -666,85 +669,13 @@ def _items_route(self): # noqa: C901 ) async def items( # noqa: C901 request: Request, - collection: Annotated[Collection, Depends(self.collection_dependency)], - ids_filter: Annotated[Optional[List[str]], Depends(ids_query)], - bbox_filter: Annotated[Optional[List[float]], Depends(bbox_query)], - datetime_filter: Annotated[Optional[List[str]], Depends(datetime_query)], - properties: Annotated[Optional[List[str]], Depends(properties_query)], - cql_filter: Annotated[Optional[AstType], Depends(filter_query)], - sortby: Annotated[Optional[str], Depends(sortby_query)], - geom_column: Annotated[ - Optional[str], - Query( - description="Select geometry column.", - alias="geom-column", - ), - ] = None, - datetime_column: Annotated[ - Optional[str], - Query( - description="Select datetime column.", - alias="datetime-column", - ), - ] = None, - limit: Annotated[ - int, - Query( - ge=0, - le=features_settings.max_features_per_query, - description="Limits the number of features in the response.", - ), - ] = features_settings.default_features_limit, - offset: Annotated[ - Optional[int], - Query( - ge=0, - description="Starts the response at an offset.", - ), - ] = None, - bbox_only: Annotated[ - Optional[bool], - Query( - description="Only return the bounding box of the feature.", - alias="bbox-only", - ), - ] = None, - simplify: Annotated[ - Optional[float], - Query( - description="Simplify the output geometry to given threshold in decimal degrees.", - ), - ] = None, + collection=Depends(self.collection_dependency), + item_list=Depends(self.items_dependency), output_type: Annotated[ Optional[MediaType], Depends(ItemsOutputType) ] = None, ): output_type = output_type or MediaType.geojson - geom_as_wkt = output_type not in [ - MediaType.geojson, - MediaType.geojsonseq, - MediaType.html, - ] - - item_list = await collection.features( - request.app.state.pool, - ids_filter=ids_filter, - bbox_filter=bbox_filter, - datetime_filter=datetime_filter, - properties_filter=properties_filter_query(request, collection), - function_parameters=function_parameters_query(request, collection), - cql_filter=cql_filter, - sortby=sortby, - properties=properties, - limit=limit, - offset=offset, - geom=geom_column, - dt=datetime_column, - bbox_only=bbox_only, - simplify=simplify, - geom_as_wkt=geom_as_wkt, - ) - if output_type in ( MediaType.csv, MediaType.json, @@ -922,69 +853,13 @@ def _item_route(self): ) async def item( request: Request, - collection: Annotated[Collection, Depends(self.collection_dependency)], - itemId: Annotated[str, Path(description="Item identifier")], - bbox_only: Annotated[ - Optional[bool], - Query( - description="Only return the bounding box of the feature.", - alias="bbox-only", - ), - ] = None, - simplify: Annotated[ - Optional[float], - Query( - description="Simplify the output geometry to given threshold in decimal degrees.", - ), - ] = None, - geom_column: Annotated[ - Optional[str], - Query( - description="Select geometry column.", - alias="geom-column", - ), - ] = None, - datetime_column: Annotated[ - Optional[str], - Query( - description="Select datetime column.", - alias="datetime-column", - ), - ] = None, - properties: Optional[List[str]] = Depends(properties_query), + collection=Depends(self.collection_dependency), + feature=Depends(self.item_dependency), output_type: Annotated[ Optional[MediaType], Depends(ItemsOutputType) ] = None, ): - if collection.id_column is None: - raise NoPrimaryKey("No primary key is set on this table") - output_type = output_type or MediaType.geojson - geom_as_wkt = output_type not in [ - MediaType.geojson, - MediaType.geojsonseq, - MediaType.html, - ] - - item_list = await collection.features( - pool=request.app.state.pool, - bbox_only=bbox_only, - simplify=simplify, - ids_filter=[itemId], - properties=properties, - function_parameters=function_parameters_query(request, collection), - geom=geom_column, - dt=datetime_column, - geom_as_wkt=geom_as_wkt, - ) - - if not item_list["items"]: - raise NotFound( - f"Item {itemId} in Collection {collection.id} does not exist." - ) - - feature = item_list["items"][0] - if output_type in ( MediaType.csv, MediaType.json, @@ -992,7 +867,7 @@ async def item( ): row = { "collectionId": collection.id, - "itemId": feature.get("id"), + "itemId": feature["id"], **feature.get("properties", {}), } if feature.get("geometry") is not None: @@ -1038,7 +913,7 @@ async def item( request, "item", collectionId=collection.id, - itemId=itemId, + itemId=feature["id"], ), "rel": "self", "type": "application/geo+json", @@ -1819,6 +1694,10 @@ class Endpoints(EndpointsFactory): # OGC Features dependency collections_dependency: Callable[..., CollectionList] = CollectionsParams + items_dependency: Callable[..., ItemList] = ItemsParams # type: ignore + + item_dependency: Callable[..., Feature] = ItemParams # type: ignore + # OGC Tiles dependency supported_tms: TileMatrixSets = default_tms with_tiles_viewer: bool = True @@ -1846,6 +1725,8 @@ def register_routes(self): self.ogc_features = OGCFeaturesFactory( collections_dependency=self.collections_dependency, collection_dependency=self.collection_dependency, + items_dependency=self.items_dependency, + item_dependency=self.item_dependency, router_prefix=self.router_prefix, templates=self.templates, # We do not want `/` and `/conformance` from the factory