-
Notifications
You must be signed in to change notification settings - Fork 105
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
Add filter extension #165
Add filter extension #165
Changes from all commits
324c115
3b21ee7
f38360e
399fc02
9b8478d
3af8d20
1645307
247098f
212781f
1f31da2
a3324d3
6f1a995
3989ce0
eec6e90
def82b1
4f1bdb6
0095fd9
e2d49c5
cbcf04a
6dea64c
8fe1283
025b31f
ddc7cb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
# encoding: utf-8 | ||
"""Filter Extension.""" | ||
|
||
from typing import Callable, List, Type, Union | ||
|
||
import attr | ||
from fastapi import APIRouter, FastAPI | ||
from starlette.responses import JSONResponse, Response | ||
|
||
from stac_fastapi.api.models import APIRequest, CollectionUri, EmptyRequest | ||
from stac_fastapi.api.routes import create_async_endpoint, create_sync_endpoint | ||
from stac_fastapi.types.core import AsyncBaseFiltersClient, BaseFiltersClient | ||
from stac_fastapi.types.extension import ApiExtension | ||
|
||
|
||
@attr.s | ||
class FilterExtension(ApiExtension): | ||
"""Filter Extension. | ||
|
||
The filter extension adds several endpoints which allow the retrieval of queryables and | ||
provides an expressive mechanism for searching based on Item Attributes: | ||
GET /queryables | ||
GET /collections/{collectionId}/queryables | ||
|
||
https://github.com/radiantearth/stac-api-spec/blob/master/fragments/filter/README.md | ||
|
||
Attributes: | ||
client: Queryables endpoint logic | ||
conformance_classes: Conformance classes provided by the extension | ||
|
||
""" | ||
|
||
client: Union[AsyncBaseFiltersClient, BaseFiltersClient] = attr.ib( | ||
factory=BaseFiltersClient | ||
) | ||
conformance_classes: List[str] = attr.ib( | ||
default=[ | ||
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter", | ||
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter:simple-cql", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI, these are about to change. radiantearth/stac-api-spec#163 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have changed the conformance classes to: default=[
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter:basic-cql",
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter:item-search-filter",
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter:cql-text",
] this was my interpretation of the PR where the basic spatial and temporal operators were recommended. I added There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I was actually just trying to let you know that they were going to change for beta.3. For beta.2, it should still use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, ok. will revert. |
||
"https://api.stacspec.org/v1.0.0-beta.2/item-search#filter:item-search-filter", | ||
] | ||
) | ||
router: APIRouter = attr.ib(factory=APIRouter) | ||
response_class: Type[Response] = attr.ib(default=JSONResponse) | ||
|
||
def _create_endpoint( | ||
self, | ||
func: Callable, | ||
request_type: Union[ | ||
Type[APIRequest], | ||
], | ||
) -> Callable: | ||
"""Create a FastAPI endpoint.""" | ||
if isinstance(self.client, AsyncBaseFiltersClient): | ||
return create_async_endpoint( | ||
func, request_type, response_class=self.response_class | ||
) | ||
if isinstance(self.client, BaseFiltersClient): | ||
return create_sync_endpoint( | ||
func, request_type, response_class=self.response_class | ||
) | ||
raise NotImplementedError | ||
|
||
def register(self, app: FastAPI) -> None: | ||
"""Register the extension with a FastAPI application. | ||
|
||
Args: | ||
app: target FastAPI application. | ||
|
||
Returns: | ||
None | ||
""" | ||
self.router.add_api_route( | ||
name="Queryables", | ||
path="/queryables", | ||
methods=["GET"], | ||
endpoint=self._create_endpoint(self.client.get_queryables, EmptyRequest), | ||
) | ||
self.router.add_api_route( | ||
name="Collection Queryables", | ||
path="/collections/{collectionId}/queryables", | ||
methods=["GET"], | ||
endpoint=self._create_endpoint(self.client.get_queryables, CollectionUri), | ||
) | ||
app.include_router(self.router, tags=["Filter Extension"]) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,7 +19,6 @@ | |
from stac_pydantic.links import Relations | ||
from stac_pydantic.version import STAC_VERSION | ||
|
||
from stac_fastapi.extensions.core import ContextExtension, FieldsExtension | ||
from stac_fastapi.sqlalchemy import serializers | ||
from stac_fastapi.sqlalchemy.models import database | ||
from stac_fastapi.sqlalchemy.session import Session | ||
|
@@ -90,7 +89,7 @@ def item_collection( | |
.order_by(self.item_table.datetime.desc(), self.item_table.id) | ||
) | ||
count = None | ||
if self.extension_is_enabled(ContextExtension): | ||
if self.extension_is_enabled("ContextExtension"): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the reasoning behind this change? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change links somewhat to the thinking which spawned #178. It would be really good to try and un-bake the extensions from the core client. The reasoning behind this change is to remove the need to import the Extension classes to check if the extension is active. The client instance has the Extension instances passed to it at runtime so I changed the |
||
count_query = collection_children.statement.with_only_columns( | ||
[func.count()] | ||
).order_by(None) | ||
|
@@ -136,7 +135,7 @@ def item_collection( | |
) | ||
|
||
context_obj = None | ||
if self.extension_is_enabled(ContextExtension): | ||
if self.extension_is_enabled("ContextExtension"): | ||
context_obj = { | ||
"returned": len(page), | ||
"limit": limit, | ||
|
@@ -279,7 +278,7 @@ def post_search( | |
) | ||
items = query.filter(id_filter).order_by(self.item_table.id) | ||
page = get_page(items, per_page=search_request.limit, page=token) | ||
if self.extension_is_enabled(ContextExtension): | ||
if self.extension_is_enabled("ContextExtension"): | ||
count = len(search_request.ids) | ||
page.next = ( | ||
self.insert_token(keyset=page.paging.bookmark_next) | ||
|
@@ -336,7 +335,7 @@ def post_search( | |
for (op, value) in expr.items(): | ||
query = query.filter(op.operator(field, value)) | ||
|
||
if self.extension_is_enabled(ContextExtension): | ||
if self.extension_is_enabled("ContextExtension"): | ||
count_query = query.statement.with_only_columns( | ||
[func.count()] | ||
).order_by(None) | ||
|
@@ -379,13 +378,15 @@ def post_search( | |
) | ||
|
||
response_features = [] | ||
filter_kwargs = {} | ||
|
||
for item in page: | ||
response_features.append( | ||
self.item_serializer.db_to_stac(item, base_url=base_url) | ||
) | ||
|
||
# Use pydantic includes/excludes syntax to implement fields extension | ||
if self.extension_is_enabled(FieldsExtension): | ||
if self.extension_is_enabled("FieldsExtension"): | ||
if search_request.query is not None: | ||
query_include: Set[str] = set( | ||
[ | ||
|
@@ -409,7 +410,7 @@ def post_search( | |
] | ||
|
||
context_obj = None | ||
if self.extension_is_enabled(ContextExtension): | ||
if self.extension_is_enabled("ContextExtension"): | ||
context_obj = { | ||
"returned": len(page), | ||
"limit": search_request.limit, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
"""Base clients.""" | ||
import abc | ||
from datetime import datetime | ||
from typing import Any, Dict, List, Optional, Type, Union | ||
from typing import Any, Dict, List, Optional, Union | ||
from urllib.parse import urljoin | ||
|
||
import attr | ||
|
@@ -315,9 +315,25 @@ def conformance_classes(self) -> List[str]: | |
|
||
return list(set(base_conformance_classes)) | ||
|
||
def extension_is_enabled(self, extension: Type[ApiExtension]) -> bool: | ||
def extension_is_enabled(self, extension: str) -> bool: | ||
"""Check if an api extension is enabled.""" | ||
return any([isinstance(ext, extension) for ext in self.extensions]) | ||
return any([type(ext).__name__ == extension for ext in self.extensions]) | ||
|
||
def list_conformance_classes(self): | ||
"""Return a list of conformance classes, including implemented extensions.""" | ||
base_conformance = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this is the set of conformance classes we want to use #159 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, these are the old conformance classes here. #159 has the most up to date ones. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. presumably we are on beta2 now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, beta.2 |
||
"https://api.stacspec.org/v1.0.0-beta.2/core", | ||
"https://api.stacspec.org/v1.0.0-beta.2/item-search", | ||
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", | ||
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", | ||
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", | ||
] | ||
|
||
for extension in self.extensions: | ||
extension_classes = getattr(extension, "conformance_classes", []) | ||
base_conformance.extend(extension_classes) | ||
|
||
return base_conformance | ||
|
||
def landing_page(self, **kwargs) -> stac_types.LandingPage: | ||
"""Landing page. | ||
|
@@ -501,9 +517,9 @@ def conformance_classes(self) -> List[str]: | |
|
||
return list(set(conformance_classes)) | ||
|
||
def extension_is_enabled(self, extension: Type[ApiExtension]) -> bool: | ||
def extension_is_enabled(self, extension: str) -> bool: | ||
"""Check if an api extension is enabled.""" | ||
return any([isinstance(ext, extension) for ext in self.extensions]) | ||
return any([type(ext).__name__ == extension for ext in self.extensions]) | ||
|
||
async def landing_page(self, **kwargs) -> stac_types.LandingPage: | ||
"""Landing page. | ||
|
@@ -656,3 +672,57 @@ async def item_collection( | |
An ItemCollection. | ||
""" | ||
... | ||
|
||
|
||
@attr.s | ||
class AsyncBaseFiltersClient(abc.ABC): | ||
"""Defines a pattern for implementing the STAC filter extension.""" | ||
|
||
async def get_queryables( | ||
self, collection_id: Optional[str] = None, **kwargs | ||
) -> Dict[str, Any]: | ||
"""Get the queryables available for the given collection_id. | ||
|
||
If collection_id is None, returns the intersection of all | ||
queryables over all collections. | ||
|
||
This base implementation returns a blank queryable schema. This is not allowed | ||
under OGC CQL but it is allowed by the STAC API Filter Extension | ||
|
||
https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables | ||
""" | ||
return { | ||
"$schema": "https://json-schema.org/draft/2019-09/schema", | ||
"$id": "https://example.org/queryables", | ||
"type": "object", | ||
"title": "Queryables for Example STAC API", | ||
"description": "Queryable names for the example STAC API Item Search filter.", | ||
"properties": {}, | ||
} | ||
|
||
|
||
@attr.s | ||
class BaseFiltersClient(abc.ABC): | ||
"""Defines a pattern for implementing the STAC filter extension.""" | ||
|
||
def get_queryables( | ||
self, collection_id: Optional[str] = None, **kwargs | ||
) -> Dict[str, Any]: | ||
"""Get the queryables available for the given collection_id. | ||
|
||
If collection_id is None, returns the intersection of all | ||
queryables over all collections. | ||
|
||
This base implementation returns a blank queryable schema. This is not allowed | ||
under OGC CQL but it is allowed by the STAC API Filter Extension | ||
|
||
https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables | ||
""" | ||
return { | ||
"$schema": "https://json-schema.org/draft/2019-09/schema", | ||
"$id": "https://example.org/queryables", | ||
"type": "object", | ||
"title": "Queryables for Example STAC API", | ||
"description": "Queryable names for the example STAC API Item Search filter.", | ||
"properties": {}, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should be class variables, as they won't change between instances of the class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While that is true in most cases, it is not in all. The filter extension is a good example. As the filter extension is complex, there are varying degrees of implementation. There is a base level to be compliant but then there are other conformance classes that are optional and implementation-specific. Having these as instance variables allows the implementor to specify which classes their implementation conforms to.
https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#conformance-classes