From 607a4bc064dce0b1f42fcffd1a127e658573ff6d Mon Sep 17 00:00:00 2001 From: Felix Delattre Date: Thu, 14 Nov 2024 21:16:34 +0100 Subject: [PATCH] Added custom runtime for eoAPI stac component. --- dockerfiles/Dockerfile.stac | 12 + runtimes/eoapi/stac/README.md | 3 + runtimes/eoapi/stac/eoapi/stac/__init__.py | 3 + runtimes/eoapi/stac/eoapi/stac/app.py | 177 +++++ runtimes/eoapi/stac/eoapi/stac/config.py | 44 ++ runtimes/eoapi/stac/eoapi/stac/extension.py | 106 +++ runtimes/eoapi/stac/eoapi/stac/logs.py | 30 + .../eoapi/stac/templates/stac-viewer.html | 702 ++++++++++++++++++ runtimes/eoapi/stac/pyproject.toml | 48 ++ 9 files changed, 1125 insertions(+) create mode 100644 dockerfiles/Dockerfile.stac create mode 100644 runtimes/eoapi/stac/README.md create mode 100644 runtimes/eoapi/stac/eoapi/stac/__init__.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/app.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/config.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/extension.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/logs.py create mode 100644 runtimes/eoapi/stac/eoapi/stac/templates/stac-viewer.html create mode 100644 runtimes/eoapi/stac/pyproject.toml diff --git a/dockerfiles/Dockerfile.stac b/dockerfiles/Dockerfile.stac new file mode 100644 index 0000000..cfcd493 --- /dev/null +++ b/dockerfiles/Dockerfile.stac @@ -0,0 +1,12 @@ +ARG PYTHON_VERSION=3.11 + +FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION} + +ENV CURL_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt + +COPY runtimes/eoapi/stac /tmp/stac +RUN python -m pip install /tmp/stac +RUN rm -rf /tmp/stac + +ENV MODULE_NAME eoapi.stac.app +ENV VARIABLE_NAME app diff --git a/runtimes/eoapi/stac/README.md b/runtimes/eoapi/stac/README.md new file mode 100644 index 0000000..2f6b3b1 --- /dev/null +++ b/runtimes/eoapi/stac/README.md @@ -0,0 +1,3 @@ +## eoapi.stac + +![](https://user-images.githubusercontent.com/10407788/151456592-f61ec158-c865-4d98-8d8b-ce05381e0e62.png) diff --git a/runtimes/eoapi/stac/eoapi/stac/__init__.py b/runtimes/eoapi/stac/eoapi/stac/__init__.py new file mode 100644 index 0000000..77dc59f --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/__init__.py @@ -0,0 +1,3 @@ +"""eoapi.stac.""" + +__version__ = "0.1.0" diff --git a/runtimes/eoapi/stac/eoapi/stac/app.py b/runtimes/eoapi/stac/eoapi/stac/app.py new file mode 100644 index 0000000..2f42296 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/app.py @@ -0,0 +1,177 @@ +"""eoapi.stac app.""" + +import logging +from contextlib import asynccontextmanager + +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 ( + ItemCollectionUri, + create_get_request_model, + create_post_request_model, + create_request_model, +) +from stac_fastapi.extensions.core import ( + FieldsExtension, + FilterExtension, + SortExtension, + TokenPaginationExtension, + TransactionExtension, +) +from stac_fastapi.extensions.third_party import BulkTransactionExtension +from stac_fastapi.pgstac.config import Settings +from stac_fastapi.pgstac.core import CoreCrudClient +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.transactions import BulkTransactionsClient, TransactionsClient +from stac_fastapi.pgstac.types.search import PgstacSearch +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import HTMLResponse +from starlette.templating import Jinja2Templates +from starlette_cramjam.middleware import CompressionMiddleware + +from .config import ApiSettings +from .extension import TiTilerExtension +from .logs import init_logging + +try: + from importlib.resources import files as resources_files # type: ignore +except ImportError: + # Try backported to PY<39 `importlib_resources`. + from importlib_resources import files as resources_files # type: ignore + + +templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore + +api_settings = ApiSettings() +auth_settings = OpenIdConnectSettings() +settings = Settings(enable_response_models=True) + +# Logs +init_logging(debug=api_settings.debug) +logger = logging.getLogger(__name__) + +# Extensions +extensions_map = { + "transaction": TransactionExtension( + client=TransactionsClient(), + settings=settings, + response_class=ORJSONResponse, + ), + "query": QueryExtension(), + "sort": SortExtension(), + "fields": FieldsExtension(), + "pagination": TokenPaginationExtension(), + "filter": FilterExtension(client=FiltersClient()), + "bulk_transactions": BulkTransactionExtension(client=BulkTransactionsClient()), + "titiler": ( + TiTilerExtension(titiler_endpoint=api_settings.titiler_endpoint) + if api_settings.titiler_endpoint + else None + ), +} + +if enabled_extensions := api_settings.extensions: + extensions = [ + extensions_map.get(name) + for name in enabled_extensions + if name in extensions_map + ] +else: + extensions = list(extensions_map.values()) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI Lifespan.""" + logger.debug("Connecting to db...") + await connect_to_db(app) + logger.debug("Connected to db.") + + yield + + logger.debug("Closing db connections...") + await close_db_connection(app) + logger.debug("Closed db connection.") + + +# Middlewares +middlewares = [Middleware(CompressionMiddleware)] +if api_settings.cors_origins: + middlewares.append( + Middleware( + CORSMiddleware, + allow_origins=api_settings.cors_origins, + allow_credentials=True, + allow_methods=api_settings.cors_methods, + allow_headers=["*"], + ) + ) + +# Custom Models +items_get_model = ItemCollectionUri +if any(isinstance(ext, TokenPaginationExtension) for ext in extensions): + items_get_model = create_request_model( + model_name="ItemCollectionUri", + base_model=ItemCollectionUri, + mixins=[TokenPaginationExtension().GET], + request_type="GET", + ) + +search_get_model = create_get_request_model(extensions) +search_post_model = create_post_request_model(extensions, base_model=PgstacSearch) + +api = StacApi( + app=FastAPI( + title=api_settings.name, + lifespan=lifespan, + openapi_url="/api", + docs_url="/api.html", + redoc_url=None, + swagger_ui_init_oauth={ + "clientId": auth_settings.client_id, + "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, + }, + ), + title=api_settings.name, + description=api_settings.name, + settings=settings, + extensions=extensions, + client=CoreCrudClient(post_request_model=search_post_model), + items_get_request_model=items_get_model, + search_get_request_model=search_get_model, + search_post_request_model=search_post_model, + response_class=ORJSONResponse, + middlewares=middlewares, +) +app = api.app + + +@app.get("/index.html", response_class=HTMLResponse) +async def viewer_page(request: Request): + """Search viewer.""" + return templates.TemplateResponse( + request, + name="stac-viewer.html", + context={ + "endpoint": str(request.url).replace("/index.html", ""), + }, + media_type="text/html", + ) + + +if auth_settings.openid_configuration_url: + oidc_auth = OpenIdConnectAuth.from_settings(auth_settings) + + restricted_prefixes = ["/collections", "/search"] + for route in app.routes: + if any( + route.path.startswith(f"{app.root_path}{prefix}") + for prefix in restricted_prefixes + ): + oidc_auth.apply_auth_dependencies(route, required_token_scopes=[]) diff --git a/runtimes/eoapi/stac/eoapi/stac/config.py b/runtimes/eoapi/stac/eoapi/stac/config.py new file mode 100644 index 0000000..1a0e4a0 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/config.py @@ -0,0 +1,44 @@ +"""API settings.""" + +from typing import List, Optional + +from pydantic import field_validator +from pydantic_settings import BaseSettings + + +class ApiSettings(BaseSettings): + """API settings""" + + name: str = "eoAPI-stac" + cors_origins: str = "*" + cors_methods: str = "GET,POST,OPTIONS" + cachecontrol: str = "public, max-age=3600" + debug: bool = False + + titiler_endpoint: Optional[str] = None + + extensions: List[str] = [ + "filter", + "query", + "sort", + "fields", + "pagination", + "titiler", + "transaction", + ] + + @field_validator("cors_origins") + def parse_cors_origin(cls, v): + """Parse CORS origins.""" + return [origin.strip() for origin in v.split(",")] + + @field_validator("cors_methods") + def parse_cors_methods(cls, v): + """Parse CORS methods.""" + return [method.strip() for method in v.split(",")] + + model_config = { + "env_prefix": "EOAPI_STAC_", + "env_file": ".env", + "extra": "allow", + } diff --git a/runtimes/eoapi/stac/eoapi/stac/extension.py b/runtimes/eoapi/stac/eoapi/stac/extension.py new file mode 100644 index 0000000..180fcd7 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/extension.py @@ -0,0 +1,106 @@ +"""TiTiler extension.""" + +from typing import Optional +from urllib.parse import urlencode + +import attr +from fastapi import APIRouter, FastAPI, HTTPException, Path, Query +from fastapi.responses import RedirectResponse +from stac_fastapi.types.extension import ApiExtension +from starlette.requests import Request + + +@attr.s(kw_only=True) +class TiTilerExtension(ApiExtension): + """TiTiler extension.""" + + titiler_endpoint: str = attr.ib() + 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.get( + "/collections/{collection_id}/items/{item_id}/tilejson.json", + ) + async def tilejson( + request: Request, + collection_id: str = Path(description="Collection ID"), + item_id: str = Path(description="Item ID"), + tile_format: Optional[str] = Query( + None, description="Output image type. Default is auto." + ), + tile_scale: int = Query( + 1, gt=0, lt=4, description="Tile size scale. 1=256x256, 2=512x512..." + ), + minzoom: Optional[int] = Query( + None, description="Overwrite default minzoom." + ), + maxzoom: Optional[int] = Query( + None, description="Overwrite default maxzoom." + ), + assets: Optional[str] = Query( # noqa + None, + description="comma (',') delimited asset names.", + ), + expression: Optional[str] = Query( # noqa + None, + description="rio-tiler's band math expression between assets (e.g asset1/asset2)", + ), + bidx: Optional[str] = Query( # noqa + None, + description="comma (',') delimited band indexes to apply to each asset", + ), + ): + """Get items and redirect to stac tiler.""" + if not assets and not expression: + raise HTTPException( + status_code=500, + detail="assets must be defined either via expression or assets options.", + ) + + qs_key_to_remove = [ + "tile_format", + "tile_scale", + "minzoom", + "maxzoom", + ] + qs = [ + (key, value) + for (key, value) in request.query_params._list + if key.lower() not in qs_key_to_remove + ] + return RedirectResponse( + f"{self.titiler_endpoint}/collections/{collection_id}/items/{item_id}/tilejson.json?{urlencode(qs)}" + ) + + @self.router.get( + "/collections/{collection_id}/items/{item_id}/viewer", + responses={ + 200: { + "description": "Redirect to TiTiler STAC viewer.", + "content": {"text/html": {}}, + } + }, + ) + async def stac_viewer( + request: Request, + collection_id: str = Path(description="Collection ID"), + item_id: str = Path(description="Item ID"), + ): + """Get items and redirect to stac tiler.""" + qs = [(key, value) for (key, value) in request.query_params._list] + url = f"{self.titiler_endpoint}/collections/{collection_id}/items/{item_id}/viewer" + if qs: + url += f"?{urlencode(qs)}" + + return RedirectResponse(url) + + app.include_router(self.router, tags=["TiTiler Extension"]) diff --git a/runtimes/eoapi/stac/eoapi/stac/logs.py b/runtimes/eoapi/stac/eoapi/stac/logs.py new file mode 100644 index 0000000..4aa9fd3 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/logs.py @@ -0,0 +1,30 @@ +from logging import config +from typing import Any, Dict + + +def init_logging(debug: bool = False, loggers: Dict[str, Any] = {}): + config.dictConfig( + # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "std": { + "format": "[%(asctime)s +0000] [%(process)d] [%(levelname)s] %(name)s: %(message)s", + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "std", + } + }, + "loggers": { + # Root logger config + "": {"handlers": ["stdout"], "level": "DEBUG" if debug else "INFO"}, + # Customized logger config + **loggers, + }, + } + ) diff --git a/runtimes/eoapi/stac/eoapi/stac/templates/stac-viewer.html b/runtimes/eoapi/stac/eoapi/stac/templates/stac-viewer.html new file mode 100644 index 0000000..c48f6c4 --- /dev/null +++ b/runtimes/eoapi/stac/eoapi/stac/templates/stac-viewer.html @@ -0,0 +1,702 @@ + + + + + Simple STAC API Viewer + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+ + + + diff --git a/runtimes/eoapi/stac/pyproject.toml b/runtimes/eoapi/stac/pyproject.toml new file mode 100644 index 0000000..f5cfcbc --- /dev/null +++ b/runtimes/eoapi/stac/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "eoapi.stac" +description = "STAC Metadata service for eoAPI." +readme = "README.md" +requires-python = ">=3.8" +authors = [ + {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: GIS", +] +dynamic = ["version"] +dependencies = [ + "stac-fastapi.pgstac==3.0.0a4", + "jinja2>=2.11.2,<4.0.0", + "starlette-cramjam>=0.3,<0.4", + "importlib_resources>=1.1.0;python_version<'3.9'", + "psycopg_pool", + "eoapi.auth-utils>=0.2.0", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "httpx", +] + +[build-system] +requires = ["pdm-pep517"] +build-backend = "pdm.pep517.api" + +[tool.pdm.version] +source = "file" +path = "eoapi/stac/__init__.py" + +[tool.pdm.build] +includes = ["eoapi/stac"] +excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"]