Skip to content

feat: add extra property to collection and collection List #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = [
Expand Down Expand Up @@ -141,7 +140,7 @@ filterwarnings = [
]

[tool.bumpversion]
current_version = "1.0.1"
current_version = "1.1.0"

search = "{current_version}"
replace = "{new_version}"
Expand Down
2 changes: 1 addition & 1 deletion tipg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""tipg."""

__version__ = "1.0.1"
__version__ = "1.1.0"
91 changes: 91 additions & 0 deletions tipg/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 37 additions & 15 deletions tipg/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
from tipg.collections import Collection, CollectionList
from tipg.dependencies import (
CollectionParams,
ExtraProperties,
CollectionExtraProperties,
CollectionsExtraPropertiesDict,
CollectionsExtraProperties,
CollectionsParams,
ItemsOutputType,
OutputType,
Expand All @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tipg/model.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"}

Expand Down