Skip to content

Commit

Permalink
Merge pull request #136 from developmentseed/refactorCatalogParams
Browse files Browse the repository at this point in the history
Refactor catalog params
  • Loading branch information
vincentsarago authored Oct 31, 2023
2 parents 1fef6fc + c71507b commit a284292
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 227 deletions.
47 changes: 47 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin

- add `py.typed` file

- add `tipg.collections.ItemList` and `tipg.collections.CollectionList` *TypedDict*

```python
class ItemList(TypedDict):
"""Items."""

items: List[Feature]
matched: Optional[int]
next: Optional[int]
prev: Optional[int]

class CollectionList(TypedDict):
"""Collections."""

collections: List[Collection]
matched: Optional[int]
next: Optional[int]
prev: Optional[int]
```

### fixed

- hide map element in HTML pages when collections/items do not have spatial component (https://github.com/developmentseed/tipg/issues/132)
Expand Down Expand Up @@ -47,6 +67,33 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin
...
```

- `Collection.features()` method now returns an `ItemList` dict

```python
#before
collection = Collection()
features_collection, matched = collection.features(...)

#now
collection = Collection()
items_list = collection.features(...)
print(items_list["matched"]) # Number of matched items for the query
print(items_list["next"]) # Next Offset
print(items_list["prev"]) # Previous Offset
```
- rename `catalog_dependency` attribute to `collections_dependency`

- move the `collections_dependency` attribute from the `EndpointsFactory` to `OGCFeaturesFactory` class

- move `/collections` QueryParameters in the `CollectionsParams` dependency

- rename `CatalogParams` to `CollectionsParams`

- the `CollectionsParams` now returns a `CollectionList` object

- move `s_intersects` and `t_intersects` functions from `tipg.factory` to `tipg.dependencies`


## [0.4.4] - 2023-10-03

### fixed
Expand Down
17 changes: 16 additions & 1 deletion docs/src/user_guide/factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
# pseudo code
class Factory:

collections_dependency: Callable
collection_dependency: Callable

def __init__(self, collection_dependency: Callable):
def __init__(self, collections_dependency: Callable, collection_dependency: Callable):
self.collections_dependency = collections_dependency
self.collection_dependency = collection_dependency
self.router = APIRouter()

self.register_routes()

def register_routes(self):

@self.router.get("/collections")
def collections(
request: Request,
collection_list=Depends(self.collections_dependency),
):
...

@self.router.get("/collections/{collectionId}")
def collection(
request: Request,
Expand All @@ -27,6 +36,7 @@ class Factory:
request: Request,
collection=Depends(self.collection_dependency),
):
item_list = collection.features(...)
...

@self.router.get("/collections/{collectionId}/items/{itemId}")
Expand All @@ -35,6 +45,7 @@ class Factory:
collection=Depends(self.collection_dependency),
itemId: str = Path(..., description="Item identifier"),
):
item_list = collection.features(ids_filter=[itemId])
...


Expand All @@ -61,6 +72,8 @@ app.include_router(endpoints.router, tags=["OGC Features API"])

#### Creation Options

- **collections_dependency** (Callable[..., tipg.collections.CollectionList]): Callable which return a CollectionList dictionary

- **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance

- **with_common** (bool, optional): Create Full OGC Features API set of endpoints with OGC Common endpoints (landing `/` and conformance `/conformance`). Defaults to `True`
Expand Down Expand Up @@ -141,6 +154,8 @@ app.include_router(endpoints.router)

#### Creation Options

- **collections_dependency** (Callable[..., tipg.collections.CollectionList]): Callable which return a CollectionList dictionary

- **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance

- **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents)
Expand Down
36 changes: 25 additions & 11 deletions tipg/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ class Feature(TypedDict, total=False):
bbox: Optional[List[float]]


class FeatureCollection(TypedDict, total=False):
"""Simple FeatureCollection model."""
class ItemList(TypedDict):
"""Items."""

type: str
features: List[Feature]
bbox: Optional[List[float]]
items: List[Feature]
matched: Optional[int]
next: Optional[int]
prev: Optional[int]


class Column(BaseModel):
Expand Down Expand Up @@ -739,8 +740,9 @@ async def features(
simplify: Optional[float] = None,
geom_as_wkt: bool = False,
function_parameters: Optional[Dict[str, str]] = None,
) -> Tuple[FeatureCollection, int]:
) -> ItemList:
"""Build and run Pg query."""
offset = offset or 0
function_parameters = function_parameters or {}

if geom and geom.lower() != "none" and not self.get_geometry_column(geom):
Expand All @@ -751,7 +753,7 @@ async def features(
f"Limit can not be set higher than the `tipg_max_features_per_query` setting of {features_settings.max_features_per_query}"
)

count = await self._features_count_query(
matched = await self._features_count_query(
pool=pool,
ids_filter=ids_filter,
datetime_filter=datetime_filter,
Expand Down Expand Up @@ -784,10 +786,13 @@ async def features(
function_parameters=function_parameters,
)
]
returned = len(features)

return (
FeatureCollection(type="FeatureCollection", features=features),
count,
return ItemList(
items=features,
matched=matched,
next=offset + returned if matched - returned > offset else None,
prev=max(offset - returned, 0) if offset else None,
)

async def get_tile(
Expand Down Expand Up @@ -876,8 +881,17 @@ def queryables(self) -> Dict:
return {**geoms, **props}


class CollectionList(TypedDict):
"""Collections."""

collections: List[Collection]
matched: Optional[int]
next: Optional[int]
prev: Optional[int]


class Catalog(TypedDict):
"""Collection Catalog."""
"""Internal Collection Catalog."""

collections: Dict[str, Collection]
last_updated: datetime.datetime
Expand Down
166 changes: 137 additions & 29 deletions tipg/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@
import re
from typing import Dict, List, Literal, Optional, Tuple, get_args

from ciso8601 import parse_rfc3339
from morecantile import Tile
from morecantile import tms as default_tms
from pygeofilter.ast import AstType
from pygeofilter.parsers.cql2_json import parse as cql2_json_parser
from pygeofilter.parsers.cql2_text import parse as cql2_text_parser
from typing_extensions import Annotated

from tipg.collections import Catalog, Collection
from tipg.collections import Catalog, Collection, CollectionList
from tipg.errors import InvalidBBox, MissingCollectionCatalog, MissingFunctionParameter
from tipg.resources.enums import MediaType
from tipg.settings import TMSSettings

from fastapi import HTTPException, Path, Query
from fastapi import Depends, HTTPException, Path, Query

from starlette.requests import Request

Expand All @@ -30,41 +31,43 @@
FilterLang = Literal["cql2-text", "cql2-json"]


def CollectionParams(
request: Request,
collectionId: Annotated[str, Path(description="Collection identifier")],
) -> Collection:
"""Return Layer Object."""
collection_pattern = re.match( # type: ignore
r"^(?P<schema>.+)\.(?P<collection>.+)$", collectionId
def s_intersects(bbox: List[float], spatial_extent: List[float]) -> bool:
"""Check if bbox intersects with spatial extent."""
return (
(bbox[0] < spatial_extent[2])
and (bbox[2] > spatial_extent[0])
and (bbox[3] > spatial_extent[1])
and (bbox[1] < spatial_extent[3])
)
if not collection_pattern:
raise HTTPException(
status_code=422, detail=f"Invalid Collection format '{collectionId}'."
)

assert collection_pattern.groupdict()["schema"]
assert collection_pattern.groupdict()["collection"]

collection_catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
if not collection_catalog:
raise MissingCollectionCatalog("Could not find collections catalog.")
def t_intersects(interval: List[str], temporal_extent: List[str]) -> bool:
"""Check if dates intersect with temporal extent."""
if len(interval) == 1:
start = end = parse_rfc3339(interval[0])

if collectionId in collection_catalog["collections"]:
return collection_catalog["collections"][collectionId]
else:
start = parse_rfc3339(interval[0]) if interval[0] not in ["..", ""] else None
end = parse_rfc3339(interval[1]) if interval[1] not in ["..", ""] else None

raise HTTPException(
status_code=404, detail=f"Table/Function '{collectionId}' not found."
)
mint, maxt = temporal_extent
min_ext = parse_rfc3339(mint) if mint is not None else None
max_ext = parse_rfc3339(maxt) if maxt is not None else None

if len(interval) == 1:
if start == min_ext or start == max_ext:
return True

def CatalogParams(request: Request) -> Catalog:
"""Return Collections Catalog."""
collection_catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
if not collection_catalog:
raise MissingCollectionCatalog("Could not find collections catalog.")
if not start:
return max_ext <= end or min_ext <= end

elif not end:
return min_ext >= start or max_ext >= start

return collection_catalog
else:
return min_ext >= start and max_ext <= end

return False


def accept_media_type(accept: str, mediatypes: List[MediaType]) -> Optional[MediaType]:
Expand Down Expand Up @@ -397,3 +400,108 @@ def function_parameters_query( # noqa: C901
)

return function_parameters


def CollectionParams(
request: Request,
collectionId: Annotated[str, Path(description="Collection identifier")],
) -> Collection:
"""Return Layer Object."""
collection_pattern = re.match( # type: ignore
r"^(?P<schema>.+)\.(?P<collection>.+)$", collectionId
)
if not collection_pattern:
raise HTTPException(
status_code=422, detail=f"Invalid Collection format '{collectionId}'."
)

assert collection_pattern.groupdict()["schema"]
assert collection_pattern.groupdict()["collection"]

catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
if not catalog:
raise MissingCollectionCatalog("Could not find collections catalog.")

if collectionId in catalog["collections"]:
return catalog["collections"][collectionId]

raise HTTPException(
status_code=404, detail=f"Table/Function '{collectionId}' not found."
)


def CollectionsParams(
request: Request,
bbox_filter: Annotated[Optional[List[float]], Depends(bbox_query)],
datetime_filter: Annotated[Optional[List[str]], Depends(datetime_query)],
type_filter: Annotated[
Optional[Literal["Function", "Table"]],
Query(alias="type", description="Filter based on Collection type."),
] = None,
limit: Annotated[
Optional[int],
Query(
ge=0,
le=1000,
description="Limits the number of collection in the response.",
),
] = None,
offset: Annotated[
Optional[int],
Query(
ge=0,
description="Starts the response at an offset.",
),
] = None,
) -> CollectionList:
"""Return Collections Catalog."""
limit = limit or 0
offset = offset or 0

catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
if not catalog:
raise MissingCollectionCatalog("Could not find collections catalog.")

collections_list = list(catalog["collections"].values())

# type filter
if type_filter is not None:
collections_list = [
collection
for collection in collections_list
if collection.type == type_filter
]

# bbox filter
if bbox_filter is not None:
collections_list = [
collection
for collection in collections_list
if collection.bounds is not None
and s_intersects(bbox_filter, collection.bounds)
]

# datetime filter
if datetime_filter is not None:
collections_list = [
collection
for collection in collections_list
if collection.dt_bounds is not None
and t_intersects(datetime_filter, collection.dt_bounds)
]

matched = len(collections_list)

if limit:
collections_list = collections_list[offset : offset + limit]
else:
collections_list = collections_list[offset:]

returned = len(collections_list)

return CollectionList(
collections=collections_list,
matched=matched,
next=offset + returned if matched - returned > offset else None,
prev=max(offset - returned, 0) if offset else None,
)
Loading

0 comments on commit a284292

Please sign in to comment.