diff --git a/runtimes/eoapi/stac/eoapi/stac/api.py b/runtimes/eoapi/stac/eoapi/stac/api.py new file mode 100644 index 0000000..0fef3d1 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/api.py @@ -0,0 +1,201 @@ +"""eoapi.stac.api: custom StacAPI class.""" + +from typing import Type + +import attr +from stac_fastapi.api import app +from stac_fastapi.api.models import APIRequest, GeoJSONResponse +from stac_fastapi.api.routes import create_async_endpoint +from stac_pydantic import api +from stac_pydantic.api.collections import Collections +from stac_pydantic.shared import MimeTypes + +from .extensions import HTMLorJSONGetRequest + + +@attr.s +class StacApi(app.StacApi): + """Custom StacAPI.""" + + landing_get_model: Type[APIRequest] = attr.ib(default=HTMLorJSONGetRequest) + conformance_get_model: Type[APIRequest] = attr.ib(default=HTMLorJSONGetRequest) + + def register_landing_page(self): + """Register landing page (GET /). + + Returns: + None + """ + self.router.add_api_route( + name="Landing Page", + path="/", + response_model=( + api.LandingPage if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + MimeTypes.html.value: {}, + }, + "model": api.LandingPage, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=False, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.landing_page, self.landing_get_model + ), + ) + + def register_conformance_classes(self): + """Register conformance classes (GET /conformance). + + Returns: + None + """ + self.router.add_api_route( + name="Conformance Classes", + path="/conformance", + response_model=( + api.Conformance if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + MimeTypes.html.value: {}, + }, + "model": api.Conformance, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.conformance, self.conformance_get_model + ), + ) + + def register_get_collections(self): + """Register get collections endpoint (GET /collections). + + Returns: + None + """ + self.router.add_api_route( + name="Get Collections", + path="/collections", + response_model=( + Collections if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + MimeTypes.html.value: {}, + }, + "model": Collections, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.all_collections, self.collections_get_request_model + ), + ) + + def register_get_collection(self): + """Register get collection endpoint (GET /collection/{collection_id}). + + Returns: + None + """ + self.router.add_api_route( + name="Get Collection", + path="/collections/{collection_id}", + response_model=api.Collection + if self.settings.enable_response_models + else None, + responses={ + 200: { + "content": { + MimeTypes.json.value: {}, + MimeTypes.html.value: {}, + }, + "model": api.Collection, + }, + }, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_collection, self.collection_get_request_model + ), + ) + + def register_get_item_collection(self): + """Register get item collection endpoint (GET /collection/{collection_id}/items). + + Returns: + None + """ + self.router.add_api_route( + name="Get ItemCollection", + path="/collections/{collection_id}/items", + response_model=( + api.ItemCollection if self.settings.enable_response_models else None + ), + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + MimeTypes.html.value: {}, + }, + "model": api.ItemCollection, + }, + }, + response_class=GeoJSONResponse, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.item_collection, self.items_get_request_model + ), + ) + + def register_get_search(self): + """Register search endpoint (GET /search). + + Returns: + None + """ + self.router.add_api_route( + name="Search", + path="/search", + response_model=api.ItemCollection + if self.settings.enable_response_models + else None, + responses={ + 200: { + "content": { + MimeTypes.geojson.value: {}, + MimeTypes.html.value: {}, + }, + "model": api.ItemCollection, + }, + }, + response_class=GeoJSONResponse, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_search, self.search_get_request_model + ), + ) diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py index af8e3f1..1d2b555 100644 --- a/runtimes/eoapi/stac/eoapi/stac/app.py +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -7,9 +7,10 @@ from eoapi.auth_utils import OpenIdConnectAuth, OpenIdConnectSettings from fastapi import FastAPI from fastapi.responses import ORJSONResponse -from stac_fastapi.api.app import StacApi from stac_fastapi.api.models import ( + CollectionUri, ItemCollectionUri, + ItemUri, create_get_request_model, create_post_request_model, create_request_model, @@ -19,9 +20,7 @@ CollectionSearchFilterExtension, FieldsExtension, FreeTextExtension, - ItemCollectionFilterExtension, OffsetPaginationExtension, - SearchFilterExtension, SortExtension, TokenPaginationExtension, ) @@ -31,7 +30,6 @@ from stac_fastapi.extensions.core.sort import SortConformanceClasses from stac_fastapi.pgstac.db import close_db_connection, connect_to_db from stac_fastapi.pgstac.extensions import QueryExtension -from stac_fastapi.pgstac.extensions.filter import FiltersClient from stac_fastapi.pgstac.types.search import PgstacSearch from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware @@ -41,9 +39,16 @@ from starlette_cramjam.middleware import CompressionMiddleware from . import __version__ as eoapi_devseed_version -from .client import PgSTACClient +from .api import StacApi +from .client import FiltersClient, PgSTACClient from .config import Settings -from .extension import TiTilerExtension +from .extensions import ( + HTMLorGeoOutputExtension, + HTMLorJSONOutputExtension, + ItemCollectionFilterExtension, + SearchFilterExtension, + TiTilerExtension, +) from .logs import init_logging jinja2_env = jinja2.Environment( @@ -77,8 +82,9 @@ QueryExtension(), SortExtension(), FieldsExtension(), - SearchFilterExtension(client=FiltersClient()), + SearchFilterExtension(client=FiltersClient()), # type: ignore TokenPaginationExtension(), + HTMLorGeoOutputExtension(), ] # collection_search extensions @@ -91,6 +97,7 @@ conformance_classes=[FreeTextConformanceClasses.COLLECTIONS], ), OffsetPaginationExtension(), + HTMLorJSONOutputExtension(), ] # item_collection extensions @@ -102,8 +109,9 @@ conformance_classes=[SortConformanceClasses.ITEMS], ), FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), - ItemCollectionFilterExtension(client=FiltersClient()), + ItemCollectionFilterExtension(client=FiltersClient()), # type: ignore TokenPaginationExtension(), + HTMLorGeoOutputExtension(), ] # Request Models @@ -128,6 +136,22 @@ collections_get_model = collection_search_extension.GET application_extensions.append(collection_search_extension) +# /collections/{collectionId} model +collection_get_model = create_request_model( + model_name="CollectionUri", + base_model=CollectionUri, + extensions=[HTMLorJSONOutputExtension()], + request_type="GET", +) + +# /collections/{collectionId}/items/itemId model +item_get_model = create_request_model( + model_name="ItemUri", + base_model=ItemUri, + extensions=[HTMLorGeoOutputExtension()], + request_type="GET", +) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -173,10 +197,12 @@ async def lifespan(app: FastAPI): description=settings.stac_fastapi_description, pgstac_search_model=search_post_model, ), + item_get_request_model=item_get_model, items_get_request_model=items_get_model, + collection_get_request_model=collection_get_model, + collections_get_request_model=collections_get_model, search_get_request_model=search_get_model, search_post_request_model=search_post_model, - collections_get_request_model=collections_get_model, response_class=ORJSONResponse, middlewares=middlewares, ) diff --git a/runtimes/eoapi/stac/eoapi/stac/client.py b/runtimes/eoapi/stac/eoapi/stac/client.py index 35bd301..bfee9d3 100644 --- a/runtimes/eoapi/stac/eoapi/stac/client.py +++ b/runtimes/eoapi/stac/eoapi/stac/client.py @@ -1,16 +1,175 @@ """eoapi-devseed: Custom pgstac client.""" -from typing import Type +import re +from typing import Any, Dict, List, Literal, Optional, Type, get_args from urllib.parse import urljoin import attr +import jinja2 from fastapi import Request from stac_fastapi.pgstac.core import CoreCrudClient +from stac_fastapi.pgstac.extensions.filter import FiltersClient as PgSTACFiltersClient from stac_fastapi.pgstac.types.search import PgstacSearch from stac_fastapi.types.requests import get_base_url -from stac_fastapi.types.stac import LandingPage +from stac_fastapi.types.stac import ( + Collection, + Collections, + Conformance, + Item, + ItemCollection, + LandingPage, +) from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes +from starlette.templating import Jinja2Templates, _TemplateResponse + +ResponseType = Literal["json", "html"] +GeoResponseType = Literal["geojson", "html"] +QueryablesResponseType = Literal["jsonschema", "html"] + + +jinja2_env = jinja2.Environment( + loader=jinja2.ChoiceLoader([jinja2.PackageLoader(__package__, "templates")]) +) +DEFAULT_TEMPLATES = Jinja2Templates(env=jinja2_env) + + +def accept_media_type(accept: str, mediatypes: List[MimeTypes]) -> Optional[MimeTypes]: + """Return MediaType based on accept header and available mediatype. + + Links: + - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept + + """ + accept_values = {} + for m in accept.replace(" ", "").split(","): + values = m.split(";") + if len(values) == 1: + name = values[0] + quality = 1.0 + else: + name = values[0] + groups = dict([param.split("=") for param in values[1:]]) # type: ignore + try: + q = groups.get("q") + quality = float(q) if q else 1.0 + except ValueError: + quality = 0 + + # if quality is 0 we ignore encoding + if quality: + accept_values[name] = quality + + # Create Preference matrix + media_preference = { + v: [n for (n, q) in accept_values.items() if q == v] + for v in sorted(set(accept_values.values()), reverse=True) + } + + # Loop through available compression and encoding preference + for _, pref in media_preference.items(): + for media in mediatypes: + if media.value in pref: + return media + + # If no specified encoding is supported but "*" is accepted, + # take one of the available compressions. + if "*" in accept_values and mediatypes: + return mediatypes[0] + + return None + + +def create_html_response( + request: Request, + data: Any, + template_name: str, + title: Optional[str] = None, + router_prefix: Optional[str] = None, + **kwargs: Any, +) -> _TemplateResponse: + """Create Template response.""" + + router_prefix = request.app.state.router_prefix + + urlpath = request.url.path + if root_path := request.app.root_path: + urlpath = re.sub(r"^" + root_path, "", urlpath) + + if router_prefix: + urlpath = re.sub(r"^" + router_prefix, "", urlpath) + + crumbs = [] + baseurl = str(request.base_url).rstrip("/") + + if router_prefix: + baseurl += router_prefix + + crumbpath = str(baseurl) + if urlpath == "/": + urlpath = "" + + for crumb in urlpath.split("/"): + crumbpath = crumbpath.rstrip("/") + part = crumb + if part is None or part == "": + part = "Home" + crumbpath += f"/{crumb}" + crumbs.append({"url": crumbpath.rstrip("/"), "part": part.capitalize()}) + + return DEFAULT_TEMPLATES.TemplateResponse( + request, + name=f"{template_name}.html", + context={ + "response": data, + "template": { + "api_root": baseurl, + "params": request.query_params, + "title": title or template_name, + }, + "crumbs": crumbs, + "url": baseurl + urlpath, + "params": str(request.url.query), + **kwargs, + }, + ) + + +@attr.s +class FiltersClient(PgSTACFiltersClient): + async def get_queryables( + self, + request: Request, + collection_id: Optional[str] = None, + *args, + f: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """Get the queryables available for the given collection_id.""" + + queryables = await super().get_queryables( + request, collection_id, *args, **kwargs + ) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(QueryablesResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + queryables, + template_name="queryables", + title=f"{collection_id} queryables", + ) + + return queryables @attr.s @@ -20,6 +179,7 @@ class PgSTACClient(CoreCrudClient): async def landing_page( self, request: Request, + f: Optional[str] = None, **kwargs, ) -> LandingPage: """Landing page. @@ -90,4 +250,202 @@ async def landing_page( } ) - return LandingPage(**landing_page) + landing = LandingPage(**landing_page) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(ResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + landing, + template_name="landing", + title=landing["title"], + ) + + return landing + + async def conformance( + self, + request: Request, + f: Optional[str] = None, + **kwargs, + ) -> Conformance: + """Conformance classes. + + Called with `GET /conformance`. + + Returns: + Conformance classes which the server conforms to. + """ + conforms_to = Conformance(conformsTo=self.conformance_classes()) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(ResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + conforms_to, + template_name="conformance", + ) + + return conforms_to + + async def all_collections( + self, + request: Request, + *args, + f: Optional[str] = None, + **kwargs, + ) -> Collections: + collections = await super().all_collections(request, *args, **kwargs) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(ResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + collections, + template_name="collections", + title="Collections list", + ) + + return collections + + async def get_collection( + self, + collection_id: str, + request: Request, + *args, + f: Optional[str] = None, + **kwargs, + ) -> Collection: + collection = await super().get_collection( + collection_id, request, *args, **kwargs + ) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(ResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + collection, + template_name="collection", + title=f"{collection_id} collection", + ) + + return collection + + async def item_collection( + self, + collection_id: str, + request: Request, + *args, + f: Optional[str] = None, + **kwargs, + ) -> ItemCollection: + items = await super().item_collection(collection_id, request, *args, **kwargs) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(GeoResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + items["id"] = collection_id + return create_html_response( + request, + items, + template_name="items", + title=f"{collection_id} items", + ) + + return items + + async def get_item( + self, + item_id: str, + collection_id: str, + request: Request, + *args, + f: Optional[str] = None, + **kwargs, + ) -> Item: + item = await super().get_item(item_id, collection_id, request, *args, **kwargs) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(GeoResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + item, + template_name="item", + title=f"{collection_id}/{item_id} item", + ) + + return item + + async def get_search( + self, + request: Request, + *args, + f: Optional[str] = None, + **kwargs, + ) -> ItemCollection: + items = await super().get_search(request, *args, **kwargs) + + output_type: Optional[MimeTypes] + if f: + output_type = MimeTypes[f] + else: + accepted_media = [MimeTypes[v] for v in get_args(GeoResponseType)] + output_type = accept_media_type( + request.headers.get("accept", ""), accepted_media + ) + + if output_type == MimeTypes.html: + return create_html_response( + request, + items, + template_name="search", + ) + + return items diff --git a/runtimes/eoapi/stac/eoapi/stac/extension.py b/runtimes/eoapi/stac/eoapi/stac/extensions.py similarity index 51% rename from runtimes/eoapi/stac/eoapi/stac/extension.py rename to runtimes/eoapi/stac/eoapi/stac/extensions.py index 180fcd7..db4a276 100644 --- a/runtimes/eoapi/stac/eoapi/stac/extension.py +++ b/runtimes/eoapi/stac/eoapi/stac/extensions.py @@ -1,12 +1,17 @@ """TiTiler extension.""" -from typing import Optional +from typing import Annotated, Literal, Optional from urllib.parse import urlencode import attr from fastapi import APIRouter, FastAPI, HTTPException, Path, Query from fastapi.responses import RedirectResponse +from stac_fastapi.api.models import CollectionUri +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.extensions import core from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import APIRequest +from stac_pydantic.shared import MimeTypes from starlette.requests import Request @@ -104,3 +109,126 @@ async def stac_viewer( return RedirectResponse(url) app.include_router(self.router, tags=["TiTiler Extension"]) + + +@attr.s +class HTMLorJSONGetRequest(APIRequest): + """HTML or JSON output.""" + + f: Annotated[ + Optional[Literal["json", "html"]], + Query(description="Response MediaType."), + ] = attr.ib(default=None) + + +@attr.s +class HTMLorGeoGetRequest(APIRequest): + """HTML or GeoJSON output.""" + + f: Annotated[ + Optional[Literal["geojson", "html"]], + Query(description="Response MediaType."), + ] = attr.ib(default=None) + + +@attr.s(kw_only=True) +class HTMLorJSONOutputExtension(ApiExtension): + """TiTiler extension.""" + + GET = HTMLorJSONGetRequest + POST = None + + def register(self, app: FastAPI) -> None: + pass + + +@attr.s(kw_only=True) +class HTMLorGeoOutputExtension(ApiExtension): + """TiTiler extension.""" + + GET = HTMLorGeoGetRequest + POST = None + + def register(self, app: FastAPI) -> None: + pass + + +@attr.s(kw_only=True) +class HTMLorSchemaGetRequest(APIRequest): + f: Annotated[ + Optional[Literal["jsonschema", "html"]], + Query(description="Response MediaType."), + ] = attr.ib(default=None) + + +@attr.s(kw_only=True) +class CollectionFilterGetRequestModel(CollectionUri, HTMLorSchemaGetRequest): + pass + + +@attr.s +class SearchFilterExtension(core.SearchFilterExtension): + """Item Search Filter Extension.""" + + 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="Queryables", + path="/queryables", + methods=["GET"], + responses={ + 200: { + "content": { + MimeTypes.jsonschema.value: {}, + MimeTypes.html.value: {}, + }, + }, + }, + response_class=self.response_class, + endpoint=create_async_endpoint( + self.client.get_queryables, HTMLorSchemaGetRequest + ), + ) + app.include_router(self.router, tags=["Filter Extension"]) + + +@attr.s +class ItemCollectionFilterExtension(core.ItemCollectionFilterExtension): + """Item Collection Filter Extension.""" + + 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="Collection Queryables", + path="/collections/{collection_id}/queryables", + methods=["GET"], + responses={ + 200: { + "content": { + MimeTypes.jsonschema.value: {}, + MimeTypes.html.value: {}, + }, + }, + }, + response_class=self.response_class, + endpoint=create_async_endpoint( + self.client.get_queryables, CollectionFilterGetRequestModel + ), + ) + app.include_router(self.router, tags=["Filter Extension"]) diff --git a/runtimes/eoapi/stac/eoapi/stac/templates/collection.html b/runtimes/eoapi/stac/eoapi/stac/templates/collection.html new file mode 100644 index 0000000..9289d93 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/templates/collection.html @@ -0,0 +1,82 @@ +{% include "header.html" %} +{% if params %} + {% set urlq = url + '?' + params + '&' %} + {% else %} + {% set urlq = url + '?' %} +{% endif %} + + + +
{{ response.description or response.title or response.id }}
+ {% if "keywords" in response and length(response.keywords) > 0 %} ++ {% for keyword in response.keywords %} + {{ keyword }} + {% endfor %} +
+
+ Number of matching collections: {{ response.numberMatched }}
+ Number of returned collections: {{ response.numberReturned }}
+ Page: of
+
| Title | +Description | +
|---|---|
| {{ collection.title or collection.id }} | +{{ collection.description or collection.title or collection.id }} | +
This API implements the conformance classes from standards and community specifications that are listed below.
+ +