Skip to content

Commit 8075fc9

Browse files
Aggregation Extension (#684)
* initial commit * aggregation extension and tests * clean up * update changelog * Search and Filter extension * AggregationCollection * AggregationCollection classes * test classes * AggregationCollection literal * aggregation post model * docstring fix * linting * TypedDict import * move aggregation client and types into extensions * linting
1 parent 07c890e commit 8075fc9

File tree

10 files changed

+454
-0
lines changed

10 files changed

+454
-0
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased] - TBD
44

5+
### Added
6+
7+
* Add base support for the Aggregation extension [#684](https://github.com/stac-utils/stac-fastapi/pull/684)
8+
59
### Changed
610

711
* moved `AsyncBaseFiltersClient` and `BaseFiltersClient` classes in `stac_fastapi.extensions.core.filter.client` submodule ([#704](https://github.com/stac-utils/stac-fastapi/pull/704))

stac_fastapi/api/stac_fastapi/api/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum):
1818
query = "query"
1919
sort = "sort"
2020
transaction = "transaction"
21+
aggregation = "aggregation"
2122

2223

2324
class AddOns(enum.Enum):

stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""stac_api.extensions.core module."""
22

3+
from .aggregation import AggregationExtension
34
from .context import ContextExtension
45
from .fields import FieldsExtension
56
from .filter import FilterExtension
@@ -9,6 +10,7 @@
910
from .transaction import TransactionExtension
1011

1112
__all__ = (
13+
"AggregationExtension",
1214
"ContextExtension",
1315
"FieldsExtension",
1416
"FilterExtension",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Aggregation extension module."""
2+
3+
from .aggregation import AggregationExtension
4+
5+
__all__ = ["AggregationExtension"]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Aggregation Extension."""
2+
from enum import Enum
3+
from typing import List, Union
4+
5+
import attr
6+
from fastapi import APIRouter, FastAPI
7+
8+
from stac_fastapi.api.models import CollectionUri, EmptyRequest
9+
from stac_fastapi.api.routes import create_async_endpoint
10+
from stac_fastapi.types.extension import ApiExtension
11+
12+
from .client import AsyncBaseAggregationClient, BaseAggregationClient
13+
from .request import AggregationExtensionGetRequest, AggregationExtensionPostRequest
14+
15+
16+
class AggregationConformanceClasses(str, Enum):
17+
"""Conformance classes for the Aggregation extension.
18+
19+
See
20+
https://github.com/stac-api-extensions/aggregation
21+
"""
22+
23+
AGGREGATION = "https://api.stacspec.org/v0.3.0/aggregation"
24+
25+
26+
@attr.s
27+
class AggregationExtension(ApiExtension):
28+
"""Aggregation Extension.
29+
30+
The purpose of the Aggregation Extension is to provide an endpoint similar to
31+
the Search endpoint (/search), but which will provide aggregated information
32+
on matching Items rather than the Items themselves. This is highly influenced
33+
by the Elasticsearch and OpenSearch aggregation endpoint, but with a more
34+
regular structure for responses.
35+
36+
The Aggregation extension adds several endpoints which allow the retrieval of
37+
available aggregation fields and aggregation buckets based on a seearch query:
38+
GET /aggregations
39+
POST /aggregations
40+
GET /collections/{collection_id}/aggregations
41+
POST /collections/{collection_id}/aggregations
42+
GET /aggregate
43+
POST /aggregate
44+
GET /collections/{collection_id}/aggregate
45+
POST /collections/{collection_id}/aggregate
46+
47+
https://github.com/stac-api-extensions/aggregation/blob/main/README.md
48+
49+
Attributes:
50+
conformance_classes: Conformance classes provided by the extension
51+
"""
52+
53+
GET = AggregationExtensionGetRequest
54+
POST = AggregationExtensionPostRequest
55+
56+
client: Union[AsyncBaseAggregationClient, BaseAggregationClient] = attr.ib(
57+
factory=BaseAggregationClient
58+
)
59+
60+
conformance_classes: List[str] = attr.ib(
61+
default=[AggregationConformanceClasses.AGGREGATION]
62+
)
63+
router: APIRouter = attr.ib(factory=APIRouter)
64+
65+
def register(self, app: FastAPI) -> None:
66+
"""Register the extension with a FastAPI application.
67+
68+
Args:
69+
app: target FastAPI application.
70+
71+
Returns:
72+
None
73+
"""
74+
self.router.prefix = app.state.router_prefix
75+
self.router.add_api_route(
76+
name="Aggregations",
77+
path="/aggregations",
78+
methods=["GET", "POST"],
79+
endpoint=create_async_endpoint(self.client.get_aggregations, EmptyRequest),
80+
)
81+
self.router.add_api_route(
82+
name="Collection Aggregations",
83+
path="/collections/{collection_id}/aggregations",
84+
methods=["GET", "POST"],
85+
endpoint=create_async_endpoint(self.client.get_aggregations, CollectionUri),
86+
)
87+
self.router.add_api_route(
88+
name="Aggregate",
89+
path="/aggregate",
90+
methods=["GET"],
91+
endpoint=create_async_endpoint(self.client.aggregate, self.GET),
92+
)
93+
self.router.add_api_route(
94+
name="Aggregate",
95+
path="/aggregate",
96+
methods=["POST"],
97+
endpoint=create_async_endpoint(self.client.aggregate, self.POST),
98+
)
99+
self.router.add_api_route(
100+
name="Collection Aggregate",
101+
path="/collections/{collection_id}/aggregate",
102+
methods=["GET"],
103+
endpoint=create_async_endpoint(self.client.aggregate, self.GET),
104+
)
105+
self.router.add_api_route(
106+
name="Collection Aggregate",
107+
path="/collections/{collection_id}/aggregate",
108+
methods=["POST"],
109+
endpoint=create_async_endpoint(self.client.aggregate, self.POST),
110+
)
111+
app.include_router(self.router, tags=["Aggregation Extension"])
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Aggregation extensions clients."""
2+
3+
import abc
4+
from typing import List, Optional, Union
5+
6+
import attr
7+
from geojson_pydantic.geometries import Geometry
8+
from stac_pydantic.shared import BBox
9+
10+
from stac_fastapi.types.rfc3339 import DateTimeType
11+
12+
from .types import Aggregation, AggregationCollection
13+
14+
15+
@attr.s
16+
class BaseAggregationClient(abc.ABC):
17+
"""Defines a pattern for implementing the STAC aggregation extension."""
18+
19+
# BUCKET = Bucket
20+
# AGGREGAION = Aggregation
21+
# AGGREGATION_COLLECTION = AggregationCollection
22+
23+
def get_aggregations(
24+
self, collection_id: Optional[str] = None, **kwargs
25+
) -> AggregationCollection:
26+
"""Get the aggregations available for the given collection_id.
27+
28+
If collection_id is None, returns the available aggregations over all
29+
collections.
30+
"""
31+
return AggregationCollection(
32+
type="AggregationCollection",
33+
aggregations=[Aggregation(name="total_count", data_type="integer")],
34+
links=[
35+
{
36+
"rel": "root",
37+
"type": "application/json",
38+
"href": "https://example.org/",
39+
},
40+
{
41+
"rel": "self",
42+
"type": "application/json",
43+
"href": "https://example.org/aggregations",
44+
},
45+
],
46+
)
47+
48+
def aggregate(
49+
self, collection_id: Optional[str] = None, **kwargs
50+
) -> AggregationCollection:
51+
"""Return the aggregation buckets for a given search result"""
52+
return AggregationCollection(
53+
type="AggregationCollection",
54+
aggregations=[],
55+
links=[
56+
{
57+
"rel": "root",
58+
"type": "application/json",
59+
"href": "https://example.org/",
60+
},
61+
{
62+
"rel": "self",
63+
"type": "application/json",
64+
"href": "https://example.org/aggregations",
65+
},
66+
],
67+
)
68+
69+
70+
@attr.s
71+
class AsyncBaseAggregationClient(abc.ABC):
72+
"""Defines an async pattern for implementing the STAC aggregation extension."""
73+
74+
# BUCKET = Bucket
75+
# AGGREGAION = Aggregation
76+
# AGGREGATION_COLLECTION = AggregationCollection
77+
78+
async def get_aggregations(
79+
self, collection_id: Optional[str] = None, **kwargs
80+
) -> AggregationCollection:
81+
"""Get the aggregations available for the given collection_id.
82+
83+
If collection_id is None, returns the available aggregations over all
84+
collections.
85+
"""
86+
return AggregationCollection(
87+
type="AggregationCollection",
88+
aggregations=[Aggregation(name="total_count", data_type="integer")],
89+
links=[
90+
{
91+
"rel": "root",
92+
"type": "application/json",
93+
"href": "https://example.org/",
94+
},
95+
{
96+
"rel": "self",
97+
"type": "application/json",
98+
"href": "https://example.org/aggregations",
99+
},
100+
],
101+
)
102+
103+
async def aggregate(
104+
self,
105+
collection_id: Optional[str] = None,
106+
aggregations: Optional[Union[str, List[str]]] = None,
107+
collections: Optional[List[str]] = None,
108+
ids: Optional[List[str]] = None,
109+
bbox: Optional[BBox] = None,
110+
intersects: Optional[Geometry] = None,
111+
datetime: Optional[DateTimeType] = None,
112+
limit: Optional[int] = 10,
113+
**kwargs,
114+
) -> AggregationCollection:
115+
"""Return the aggregation buckets for a given search result"""
116+
return AggregationCollection(
117+
type="AggregationCollection",
118+
aggregations=[],
119+
links=[
120+
{
121+
"rel": "root",
122+
"type": "application/json",
123+
"href": "https://example.org/",
124+
},
125+
{
126+
"rel": "self",
127+
"type": "application/json",
128+
"href": "https://example.org/aggregations",
129+
},
130+
],
131+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Request model for the Aggregation extension."""
2+
3+
from typing import List, Optional, Union
4+
5+
import attr
6+
7+
from stac_fastapi.extensions.core.filter.request import (
8+
FilterExtensionGetRequest,
9+
FilterExtensionPostRequest,
10+
)
11+
from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest
12+
13+
14+
@attr.s
15+
class AggregationExtensionGetRequest(BaseSearchGetRequest, FilterExtensionGetRequest):
16+
"""Aggregation Extension GET request model."""
17+
18+
aggregations: Optional[str] = attr.ib(default=None)
19+
20+
21+
class AggregationExtensionPostRequest(BaseSearchPostRequest, FilterExtensionPostRequest):
22+
"""Aggregation Extension POST request model."""
23+
24+
aggregations: Optional[Union[str, List[str]]] = attr.ib(default=None)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Aggregation Extension types."""
2+
3+
from typing import Any, Dict, List, Literal, Optional, Union
4+
5+
from pydantic import Field
6+
from typing_extensions import TypedDict
7+
8+
from stac_fastapi.types.rfc3339 import DateTimeType
9+
10+
11+
class Bucket(TypedDict, total=False):
12+
"""A STAC aggregation bucket."""
13+
14+
key: str
15+
data_type: str
16+
frequency: Optional[Dict] = None
17+
_from: Optional[Union[int, float]] = Field(alias="from", default=None)
18+
to: Optional[Optional[Union[int, float]]] = None
19+
20+
21+
class Aggregation(TypedDict, total=False):
22+
"""A STAC aggregation."""
23+
24+
name: str
25+
data_type: str
26+
buckets: Optional[List[Bucket]] = None
27+
overflow: Optional[int] = None
28+
value: Optional[Union[str, int, DateTimeType]] = None
29+
30+
31+
class AggregationCollection(TypedDict, total=False):
32+
"""STAC Item Aggregation Collection."""
33+
34+
type: Literal["AggregationCollection"]
35+
aggregations: List[Aggregation]
36+
links: List[Dict[str, Any]]

0 commit comments

Comments
 (0)