Skip to content
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

Aggregation Extension #684

Merged
merged 23 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Added

* Add base support for the Aggregation extension [#684](https://github.com/stac-utils/stac-fastapi/pull/684)

## [3.0.0a0] - 2024-05-06

### Added
Expand Down
1 change: 1 addition & 0 deletions stac_fastapi/api/stac_fastapi/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum):
query = "query"
sort = "sort"
transaction = "transaction"
aggregation = "aggregation"


class AddOns(enum.Enum):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""stac_api.extensions.core module."""

from .aggregation import AggregationExtension
from .context import ContextExtension
from .fields import FieldsExtension
from .filter import FilterExtension
Expand All @@ -9,6 +10,7 @@
from .transaction import TransactionExtension

__all__ = (
"AggregationExtension",
"ContextExtension",
"FieldsExtension",
"FilterExtension",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Aggregation extension module."""

from .aggregation import AggregationExtension

__all__ = ["AggregationExtension"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Aggregation Extension."""
from enum import Enum
from typing import List, Union

import attr
from fastapi import APIRouter, FastAPI

from stac_fastapi.api.models import CollectionUri, EmptyRequest
from stac_fastapi.api.routes import create_async_endpoint
from stac_fastapi.types.core import AsyncBaseAggregationClient, BaseAggregationClient
from stac_fastapi.types.extension import ApiExtension

from .request import AggregationExtensionGetRequest, AggregationExtensionPostRequest


class AggregationConformanceClasses(str, Enum):
"""Conformance classes for the Aggregation extension.

See
https://github.com/stac-api-extensions/aggregation
"""

AGGREGATION = "https://api.stacspec.org/v0.3.0/aggregation"


@attr.s
class AggregationExtension(ApiExtension):
"""Aggregation Extension.

The purpose of the Aggregation Extension is to provide an endpoint similar to
the Search endpoint (/search), but which will provide aggregated information
on matching Items rather than the Items themselves. This is highly influenced
by the Elasticsearch and OpenSearch aggregation endpoint, but with a more
regular structure for responses.

The Aggregation extension adds several endpoints which allow the retrieval of
available aggregation fields and aggregation buckets based on a seearch query:
GET /aggregation
jamesfisher-geo marked this conversation as resolved.
Show resolved Hide resolved
GET /aggregate
GET /collections/{collection_id}/aggregations
GET /collections/{collection_id}/aggregate

https://github.com/stac-api-extensions/aggregation/blob/main/README.md

Attributes:
conformance_classes: Conformance classes provided by the extension
"""

GET = AggregationExtensionGetRequest
POST = AggregationExtensionPostRequest

client: Union[AsyncBaseAggregationClient, BaseAggregationClient] = attr.ib(
factory=BaseAggregationClient
)

conformance_classes: List[str] = attr.ib(
default=[AggregationConformanceClasses.AGGREGATION]
)
router: APIRouter = attr.ib(factory=APIRouter)

def register(self, app: FastAPI) -> None:
"""Register the extension with a FastAPI application.

Args:
app: target FastAPI application.

Returns:
None
"""
self.router.prefix = app.state.router_prefix
self.router.add_api_route(
name="Aggregations",
path="/aggregations",
methods=["GET", "POST"],
endpoint=create_async_endpoint(self.client.get_aggregations, EmptyRequest),
)
self.router.add_api_route(
name="Collection Aggregations",
path="/collections/{collection_id}/aggregations",
methods=["GET", "POST"],
endpoint=create_async_endpoint(self.client.get_aggregations, CollectionUri),
)
self.router.add_api_route(
name="Aggregate",
path="/aggregate",
methods=["GET", "POST"],
endpoint=create_async_endpoint(self.client.aggregate, self.GET),
)
self.router.add_api_route(
name="Collection Aggregate",
path="/collections/{collection_id}/aggregate",
methods=["GET", "POST"],
endpoint=create_async_endpoint(self.client.aggregate, self.GET),
)
app.include_router(self.router, tags=["Aggregation Extension"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Request model for the Aggregation extension."""

from typing import Optional

import attr
from pydantic import BaseModel

from stac_fastapi.types.search import APIRequest


@attr.s
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can filter be included in this? I'm not sure how the extensions interact.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have tested with filter included on an implementation and it works great.

I am also not clear on how extension interact. For instance, could collections, datetime, bbox, and intersects be excluded here since they are part of the core search? I'm inclined to keep so the request class correlates directly to the extension spec.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i think you should extend BaseSearchGetRequest and BaseSearchPostRequest and just add the aggregations param. You also pickup limit that's ignored, but that's find for a datamodel like this since there's a lot less duplication

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed it to be build off base Search and Filter. This makes the implementation dependent on the Filter extension. But I don't think that is an issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamesfisher-gis sorry I'm just realizing this now but I don't think we should do this, at least I don't see in the aggregate extension why the filter attributes should be enabled with the aggregations ones.

I think it will be up to the implementers to add the filter extension and also handle them in the client.

Copy link
Contributor Author

@jamesfisher-geo jamesfisher-geo Jun 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey. That makes sense.

I have a couple other small bug fixes for aggregation. I will submit a PR today that removes the Filter extension dependency.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe wait because I will submit a PR that takes care of #713 soon

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamesfisher-gis in fact, go ahead because my PR is a no-go in fact 😓

class AggregationExtensionGetRequest(APIRequest):
"""Aggregation Extension GET request model."""

aggregations: Optional[str] = attr.ib(default=None)
bbox: Optional[str] = attr.ib(default=None)
intersects: Optional[str] = attr.ib(default=None)
ids: Optional[str] = attr.ib(default=None)
datetime: Optional[str] = attr.ib(default=None)
collections: Optional[str] = attr.ib(default=None)


class AggregationExtensionPostRequest(BaseModel):
"""Aggregation Extension POST request model."""

aggregations: Optional[str] = attr.ib(default=None)
bbox: Optional[str] = attr.ib(default=None)
intersects: Optional[str] = attr.ib(default=None)
ids: Optional[str] = attr.ib(default=None)
datetime: Optional[str] = attr.ib(default=None)
collections: Optional[str] = attr.ib(default=None)
107 changes: 107 additions & 0 deletions stac_fastapi/extensions/tests/test_aggregation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from typing import Iterator

import pytest
from starlette.testclient import TestClient

from stac_fastapi.api.app import StacApi
from stac_fastapi.extensions.core import AggregationExtension
from stac_fastapi.types.config import ApiSettings
from stac_fastapi.types.core import BaseAggregationClient, BaseCoreClient


class DummyCoreClient(BaseCoreClient):
def all_collections(self, *args, **kwargs):
raise NotImplementedError

def get_collection(self, *args, **kwargs):
raise NotImplementedError

def get_item(self, *args, **kwargs):
raise NotImplementedError

def get_search(self, *args, **kwargs):
raise NotImplementedError

def post_search(self, *args, **kwargs):
raise NotImplementedError

def item_collection(self, *args, **kwargs):
raise NotImplementedError


class DummyAggregationsClient(BaseAggregationClient):
"""Dummy client returning parts of the request, rather than proper STAC items."""

def get_aggregations(self, *args, **kwargs):
return {
"aggregations": [{"name": "total_count", "data_type": "integer"}],
"links": [
{
"rel": "root",
"type": "application/json",
"href": "https://example.org",
},
{
"rel": "self",
"type": "application/json",
"href": "https://example.org/aggregations",
},
],
}

def aggregate(self, *args, **kwargs):
return {
"aggregations": [],
"links": [
{
"rel": "self",
"type": "application/json",
"href": "https://example.org/aggregate",
},
{
"rel": "root",
"type": "application/json",
"href": "https://example.org",
},
],
}


def test_get_aggregations(client: TestClient) -> None:
response = client.get("/aggregations")
assert response.is_success, response.text
assert response.json()["aggregations"] == [
{"name": "total_count", "data_type": "integer"}
]


def test_aggregate(client: TestClient) -> None:
response = client.get("/aggregate")
assert response.is_success, response.text
assert response.json()["aggregations"] == []


@pytest.fixture
def client(
core_client: DummyCoreClient, aggregations_client: DummyAggregationsClient
) -> Iterator[TestClient]:
settings = ApiSettings()
api = StacApi(
settings=settings,
client=core_client,
extensions=[
AggregationExtension(client=aggregations_client),
],
)
with TestClient(api.app) as client:
yield client


@pytest.fixture
def core_client() -> DummyCoreClient:
return DummyCoreClient()


@pytest.fixture
def aggregations_client() -> DummyAggregationsClient:
return DummyAggregationsClient()
Loading