Skip to content

Commit a284292

Browse files
Merge pull request #136 from developmentseed/refactorCatalogParams
Refactor catalog params
2 parents 1fef6fc + c71507b commit a284292

File tree

6 files changed

+287
-227
lines changed

6 files changed

+287
-227
lines changed

CHANGES.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin
1212

1313
- add `py.typed` file
1414

15+
- add `tipg.collections.ItemList` and `tipg.collections.CollectionList` *TypedDict*
16+
17+
```python
18+
class ItemList(TypedDict):
19+
"""Items."""
20+
21+
items: List[Feature]
22+
matched: Optional[int]
23+
next: Optional[int]
24+
prev: Optional[int]
25+
26+
class CollectionList(TypedDict):
27+
"""Collections."""
28+
29+
collections: List[Collection]
30+
matched: Optional[int]
31+
next: Optional[int]
32+
prev: Optional[int]
33+
```
34+
1535
### fixed
1636

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

70+
- `Collection.features()` method now returns an `ItemList` dict
71+
72+
```python
73+
#before
74+
collection = Collection()
75+
features_collection, matched = collection.features(...)
76+
77+
#now
78+
collection = Collection()
79+
items_list = collection.features(...)
80+
print(items_list["matched"]) # Number of matched items for the query
81+
print(items_list["next"]) # Next Offset
82+
print(items_list["prev"]) # Previous Offset
83+
```
84+
- rename `catalog_dependency` attribute to `collections_dependency`
85+
86+
- move the `collections_dependency` attribute from the `EndpointsFactory` to `OGCFeaturesFactory` class
87+
88+
- move `/collections` QueryParameters in the `CollectionsParams` dependency
89+
90+
- rename `CatalogParams` to `CollectionsParams`
91+
92+
- the `CollectionsParams` now returns a `CollectionList` object
93+
94+
- move `s_intersects` and `t_intersects` functions from `tipg.factory` to `tipg.dependencies`
95+
96+
5097
## [0.4.4] - 2023-10-03
5198

5299
### fixed

docs/src/user_guide/factories.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,25 @@
55
# pseudo code
66
class Factory:
77

8+
collections_dependency: Callable
89
collection_dependency: Callable
910

10-
def __init__(self, collection_dependency: Callable):
11+
def __init__(self, collections_dependency: Callable, collection_dependency: Callable):
12+
self.collections_dependency = collections_dependency
1113
self.collection_dependency = collection_dependency
1214
self.router = APIRouter()
1315

1416
self.register_routes()
1517

1618
def register_routes(self):
1719

20+
@self.router.get("/collections")
21+
def collections(
22+
request: Request,
23+
collection_list=Depends(self.collections_dependency),
24+
):
25+
...
26+
1827
@self.router.get("/collections/{collectionId}")
1928
def collection(
2029
request: Request,
@@ -27,6 +36,7 @@ class Factory:
2736
request: Request,
2837
collection=Depends(self.collection_dependency),
2938
):
39+
item_list = collection.features(...)
3040
...
3141

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

4051

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

6273
#### Creation Options
6374

75+
- **collections_dependency** (Callable[..., tipg.collections.CollectionList]): Callable which return a CollectionList dictionary
76+
6477
- **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance
6578

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

142155
#### Creation Options
143156

157+
- **collections_dependency** (Callable[..., tipg.collections.CollectionList]): Callable which return a CollectionList dictionary
158+
144159
- **collection_dependency** (Callable[..., tipg.collections.Collection]): Callable which return a Collection instance
145160

146161
- **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents)

tipg/collections.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,13 @@ class Feature(TypedDict, total=False):
7676
bbox: Optional[List[float]]
7777

7878

79-
class FeatureCollection(TypedDict, total=False):
80-
"""Simple FeatureCollection model."""
79+
class ItemList(TypedDict):
80+
"""Items."""
8181

82-
type: str
83-
features: List[Feature]
84-
bbox: Optional[List[float]]
82+
items: List[Feature]
83+
matched: Optional[int]
84+
next: Optional[int]
85+
prev: Optional[int]
8586

8687

8788
class Column(BaseModel):
@@ -739,8 +740,9 @@ async def features(
739740
simplify: Optional[float] = None,
740741
geom_as_wkt: bool = False,
741742
function_parameters: Optional[Dict[str, str]] = None,
742-
) -> Tuple[FeatureCollection, int]:
743+
) -> ItemList:
743744
"""Build and run Pg query."""
745+
offset = offset or 0
744746
function_parameters = function_parameters or {}
745747

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

754-
count = await self._features_count_query(
756+
matched = await self._features_count_query(
755757
pool=pool,
756758
ids_filter=ids_filter,
757759
datetime_filter=datetime_filter,
@@ -784,10 +786,13 @@ async def features(
784786
function_parameters=function_parameters,
785787
)
786788
]
789+
returned = len(features)
787790

788-
return (
789-
FeatureCollection(type="FeatureCollection", features=features),
790-
count,
791+
return ItemList(
792+
items=features,
793+
matched=matched,
794+
next=offset + returned if matched - returned > offset else None,
795+
prev=max(offset - returned, 0) if offset else None,
791796
)
792797

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

878883

884+
class CollectionList(TypedDict):
885+
"""Collections."""
886+
887+
collections: List[Collection]
888+
matched: Optional[int]
889+
next: Optional[int]
890+
prev: Optional[int]
891+
892+
879893
class Catalog(TypedDict):
880-
"""Collection Catalog."""
894+
"""Internal Collection Catalog."""
881895

882896
collections: Dict[str, Collection]
883897
last_updated: datetime.datetime

tipg/dependencies.py

Lines changed: 137 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
import re
44
from typing import Dict, List, Literal, Optional, Tuple, get_args
55

6+
from ciso8601 import parse_rfc3339
67
from morecantile import Tile
78
from morecantile import tms as default_tms
89
from pygeofilter.ast import AstType
910
from pygeofilter.parsers.cql2_json import parse as cql2_json_parser
1011
from pygeofilter.parsers.cql2_text import parse as cql2_text_parser
1112
from typing_extensions import Annotated
1213

13-
from tipg.collections import Catalog, Collection
14+
from tipg.collections import Catalog, Collection, CollectionList
1415
from tipg.errors import InvalidBBox, MissingCollectionCatalog, MissingFunctionParameter
1516
from tipg.resources.enums import MediaType
1617
from tipg.settings import TMSSettings
1718

18-
from fastapi import HTTPException, Path, Query
19+
from fastapi import Depends, HTTPException, Path, Query
1920

2021
from starlette.requests import Request
2122

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

3233

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

46-
assert collection_pattern.groupdict()["schema"]
47-
assert collection_pattern.groupdict()["collection"]
4843

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

53-
if collectionId in collection_catalog["collections"]:
54-
return collection_catalog["collections"][collectionId]
49+
else:
50+
start = parse_rfc3339(interval[0]) if interval[0] not in ["..", ""] else None
51+
end = parse_rfc3339(interval[1]) if interval[1] not in ["..", ""] else None
5552

56-
raise HTTPException(
57-
status_code=404, detail=f"Table/Function '{collectionId}' not found."
58-
)
53+
mint, maxt = temporal_extent
54+
min_ext = parse_rfc3339(mint) if mint is not None else None
55+
max_ext = parse_rfc3339(maxt) if maxt is not None else None
5956

57+
if len(interval) == 1:
58+
if start == min_ext or start == max_ext:
59+
return True
6060

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

67-
return collection_catalog
67+
else:
68+
return min_ext >= start and max_ext <= end
69+
70+
return False
6871

6972

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

399402
return function_parameters
403+
404+
405+
def CollectionParams(
406+
request: Request,
407+
collectionId: Annotated[str, Path(description="Collection identifier")],
408+
) -> Collection:
409+
"""Return Layer Object."""
410+
collection_pattern = re.match( # type: ignore
411+
r"^(?P<schema>.+)\.(?P<collection>.+)$", collectionId
412+
)
413+
if not collection_pattern:
414+
raise HTTPException(
415+
status_code=422, detail=f"Invalid Collection format '{collectionId}'."
416+
)
417+
418+
assert collection_pattern.groupdict()["schema"]
419+
assert collection_pattern.groupdict()["collection"]
420+
421+
catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
422+
if not catalog:
423+
raise MissingCollectionCatalog("Could not find collections catalog.")
424+
425+
if collectionId in catalog["collections"]:
426+
return catalog["collections"][collectionId]
427+
428+
raise HTTPException(
429+
status_code=404, detail=f"Table/Function '{collectionId}' not found."
430+
)
431+
432+
433+
def CollectionsParams(
434+
request: Request,
435+
bbox_filter: Annotated[Optional[List[float]], Depends(bbox_query)],
436+
datetime_filter: Annotated[Optional[List[str]], Depends(datetime_query)],
437+
type_filter: Annotated[
438+
Optional[Literal["Function", "Table"]],
439+
Query(alias="type", description="Filter based on Collection type."),
440+
] = None,
441+
limit: Annotated[
442+
Optional[int],
443+
Query(
444+
ge=0,
445+
le=1000,
446+
description="Limits the number of collection in the response.",
447+
),
448+
] = None,
449+
offset: Annotated[
450+
Optional[int],
451+
Query(
452+
ge=0,
453+
description="Starts the response at an offset.",
454+
),
455+
] = None,
456+
) -> CollectionList:
457+
"""Return Collections Catalog."""
458+
limit = limit or 0
459+
offset = offset or 0
460+
461+
catalog: Catalog = getattr(request.app.state, "collection_catalog", None)
462+
if not catalog:
463+
raise MissingCollectionCatalog("Could not find collections catalog.")
464+
465+
collections_list = list(catalog["collections"].values())
466+
467+
# type filter
468+
if type_filter is not None:
469+
collections_list = [
470+
collection
471+
for collection in collections_list
472+
if collection.type == type_filter
473+
]
474+
475+
# bbox filter
476+
if bbox_filter is not None:
477+
collections_list = [
478+
collection
479+
for collection in collections_list
480+
if collection.bounds is not None
481+
and s_intersects(bbox_filter, collection.bounds)
482+
]
483+
484+
# datetime filter
485+
if datetime_filter is not None:
486+
collections_list = [
487+
collection
488+
for collection in collections_list
489+
if collection.dt_bounds is not None
490+
and t_intersects(datetime_filter, collection.dt_bounds)
491+
]
492+
493+
matched = len(collections_list)
494+
495+
if limit:
496+
collections_list = collections_list[offset : offset + limit]
497+
else:
498+
collections_list = collections_list[offset:]
499+
500+
returned = len(collections_list)
501+
502+
return CollectionList(
503+
collections=collections_list,
504+
matched=matched,
505+
next=offset + returned if matched - returned > offset else None,
506+
prev=max(offset - returned, 0) if offset else None,
507+
)

0 commit comments

Comments
 (0)