diff --git a/CHANGES.md b/CHANGES.md index 990b4523..c71ec26c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,10 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin ## [unreleased] +## [1.1.0] - 2025-05-06 + +* update geojson-pydantic requirement to `>=1.0,<3.0` + ## [1.0.1] - 2025-03-17 * fix typo when using catalog_ttl @@ -390,7 +394,8 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin - Initial release -[unreleased]: https://github.com/developmentseed/tipg/compare/1.0.1...HEAD +[unreleased]: https://github.com/developmentseed/tipg/compare/1.1.0...HEAD +[1.1.0]: https://github.com/developmentseed/tipg/compare/1.0.1...1.1.0 [1.0.1]: https://github.com/developmentseed/tipg/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/developmentseed/tipg/compare/0.10.0...1.0.0 [0.10.1]: https://github.com/developmentseed/tipg/compare/0.10.0...0.10.1 diff --git a/pyproject.toml b/pyproject.toml index 02fd5574..b0ece9c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "morecantile>=5.0,<7.0", "pydantic>=2.4,<3.0", "pydantic-settings~=2.0", - "geojson-pydantic>=1.0,<2.0", + "geojson-pydantic>=1.0,<3.0", "pygeofilter>=0.2.0,<0.3.0", "ciso8601~=2.3", "starlette-cramjam>=0.4,<0.5", @@ -44,8 +44,7 @@ test = [ "pytest-benchmark", "httpx", "pytest-postgresql", - "mapbox-vector-tile", - "protobuf>=3.0,<4.0", + "mapbox-vector-tile>=2.1", "numpy", ] dev = [ @@ -141,7 +140,7 @@ filterwarnings = [ ] [tool.bumpversion] -current_version = "1.0.1" +current_version = "1.1.0" search = "{current_version}" replace = "{new_version}" diff --git a/tipg/__init__.py b/tipg/__init__.py index dde05d2e..61decb9a 100644 --- a/tipg/__init__.py +++ b/tipg/__init__.py @@ -1,3 +1,3 @@ """tipg.""" -__version__ = "1.0.1" +__version__ = "1.1.0" diff --git a/tipg/dependencies.py b/tipg/dependencies.py index a82feeb7..f94251b2 100644 --- a/tipg/dependencies.py +++ b/tipg/dependencies.py @@ -502,3 +502,94 @@ def CollectionsParams( next=offset + returned if matched - returned > offset else None, prev=max(offset - limit, 0) if offset else None, ) + +ExtraProperties = Dict[str, any] + +async def CollectionExtraProperties( + request: Request, + collection: Annotated[Collection, Depends(CollectionParams)], +) -> ExtraProperties: + """ + Extracts extra properties from the first feature of a collection. (First Item has the highest priority) + - This is done because there is no separate table to store the collection specific information. + To elaborate, a schema table represents the whole collection and a rows represents items. + So, If we add any columns with prefix "collection_properties_", we use that as extra properties. + - This is needed to store metadata information about the collection. + - This is named extraProperties as there was another key named properties defined already for the Collection. + + This function attempts to retrieve features from the provided collection. + If successful, it inspects the properties of the first feature and + filters them to include only those whose keys contain the prefix "collection_properties_". + + Args: + request: The incoming Starlette/FastAPI request object. + collection: The collection object, typically resolved by FastAPI's + dependency injection system using `CollectionParams`. + + Returns: + A dictionary containing the extra properties (keys containing "collection_properties_") + from the first feature of the collection. Returns an empty dictionary + if the collection features are not callable, if no features are found, + or if an error occurs during processing. + """ + # First come first serve to get the collection properties + extra_properties_dict={} + extra_properties_prefix = "collection_properties_" + + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + return extra_properties_dict + +CollectionsExtraPropertiesDict = Dict[str, ExtraProperties] + +async def CollectionsExtraProperties( + request: Request, + collections: Annotated[CollectionList, Depends(CollectionsParams)], +) -> CollectionsExtraPropertiesDict: + """ + For all the list of available collection, + Extracts extra properties from the first feature of a collection. (First Item has the highest priority) + - This is done because there is no separate table to store the collection specific information. + To elaborate, a schema table represents the whole collection and a rows represents items. + So, If we add any columns with prefix "collection_properties_", we use that as extra properties. + - This is needed to store metadata information about the collection. + - This is named extraProperties as there was another key named properties defined already for the Collection. + + This function attempts to retrieve features from the provided collection. + If successful, it inspects the properties of the first feature and + filters them to include only those whose keys contain the prefix "collection_properties_". + + Args: + request: The incoming Starlette/FastAPI request object. + collections: The collection list, typically resolved by FastAPI's + dependency injection system using `CollectionsParams`. + + Returns: + A dictionary containing the collection.id as key and extra properties (keys containing "collection_properties_") as value + """ + collections_extra_properties = {} + extra_properties_prefix = "collection_properties_" + + for collection in collections["collections"]: + collection_id = collection.id + extra_properties_dict={} + + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + collections_extra_properties[collection_id] = extra_properties_dict + + return collections_extra_properties diff --git a/tipg/factory.py b/tipg/factory.py index 11550946..51fafcc5 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -28,6 +28,10 @@ from tipg.collections import Collection, CollectionList from tipg.dependencies import ( CollectionParams, + ExtraProperties, + CollectionExtraProperties, + CollectionsExtraPropertiesDict, + CollectionsExtraProperties, CollectionsParams, ItemsOutputType, OutputType, @@ -44,11 +48,15 @@ ) from tipg.errors import MissingGeometryColumn, NoPrimaryKey, NotFound from tipg.resources.enums import MediaType -from tipg.resources.response import GeoJSONResponse, SchemaJSONResponse, orjsonDumps +from tipg.resources.response import ( + GeoJSONResponse, + ORJSONResponse, + SchemaJSONResponse, + orjsonDumps, +) from tipg.settings import FeaturesSettings, MVTSettings, TMSSettings from fastapi import APIRouter, Depends, Path, Query -from fastapi.responses import ORJSONResponse from starlette.datastructures import QueryParams from starlette.requests import Request @@ -183,6 +191,9 @@ class EndpointsFactory(metaclass=abc.ABCMeta): # collection dependency collection_dependency: Callable[..., Collection] = CollectionParams + # collection extra-properties dependency needed for collection metadata + collection_extra_properties: Callable[..., ExtraProperties] = CollectionExtraProperties + # Router Prefix is needed to find the path for routes when prefixed # e.g if you mount the route with `/foo` prefix, set router_prefix to foo router_prefix: str = "" @@ -359,6 +370,9 @@ class OGCFeaturesFactory(EndpointsFactory): # collections dependency collections_dependency: Callable[..., CollectionList] = CollectionsParams + # collections extra-properties dependency needed for collection metadata list + collections_extra_properties: Callable[..., CollectionsExtraPropertiesDict] = CollectionsExtraProperties + @property def conforms_to(self) -> List[str]: """Factory conformances.""" @@ -499,12 +513,16 @@ def _collections_route(self): # noqa: C901 }, tags=["OGC Features API"], ) - def collections( + async def collections( request: Request, collection_list: Annotated[ CollectionList, Depends(self.collections_dependency), ], + collections_extra_properties_dictionary: Annotated[ + CollectionsExtraPropertiesDict, + Depends(self.collections_extra_properties) + ], output_type: Annotated[ Optional[MediaType], Depends(OutputType), @@ -561,6 +579,7 @@ def collections( title=collection.id, description=collection.description, extent=collection.extent, + extraProperties=collections_extra_properties_dictionary[collection.id], links=[ model.Link( href=self.url_for( @@ -592,21 +611,21 @@ def collections( *self._additional_collection_tiles_links( request, collection ), - ], + ] ) for collection in collection_list["collections"] - ], - ) + ] + ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="collections", title="Collections list", ) - return data + return ORJSONResponse(data) def _collection_route(self): @self.router.get( @@ -624,17 +643,20 @@ def _collection_route(self): }, tags=["OGC Features API"], ) - def collection( + async def collection( request: Request, collection: Annotated[Collection, Depends(self.collection_dependency)], + extraProperties: Annotated[Dict, Depends(self.collection_extra_properties)], output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): """Metadata for a feature collection.""" + data = model.Collection( id=collection.id, title=collection.title, description=collection.description, extent=collection.extent, + extraProperties=extraProperties, links=[ model.Link( title="Collection", @@ -689,17 +711,17 @@ def collection( ), *self._additional_collection_tiles_links(request, collection), ], - ) + ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="collection", title=f"{collection.id} collection", ) - return data + return ORJSONResponse(data) def _queryables_route(self): @self.router.get( @@ -735,17 +757,17 @@ def queryables( title=collection.id, link=self_url + qs, properties=collection.queryables, - ) + ).model_dump(exclude_none=True, mode="json", by_alias=True) if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="queryables", title=f"{collection.id} queryables", ) - return data + return SchemaJSONResponse(data) def _items_route(self): # noqa: C901 @self.router.get( diff --git a/tipg/model.py b/tipg/model.py index 0028e971..1cd54340 100644 --- a/tipg/model.py +++ b/tipg/model.py @@ -1,7 +1,7 @@ """tipg models.""" from datetime import datetime -from typing import Annotated, Dict, List, Literal, Optional, Set, Tuple, Union +from typing import Annotated, Any, Dict, List, Literal, Optional, Set, Tuple, Union from geojson_pydantic.features import Feature, FeatureCollection from morecantile.models import CRSType @@ -145,6 +145,7 @@ class Collection(BaseModel): extent: Optional[Extent] = None itemType: str = "feature" crs: List[str] = ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"] + extraProperties: Optional[Dict[str, Any]] = None model_config = {"extra": "ignore"}