Skip to content

Commit

Permalink
Added custom runtime for eoAPI stac component. (#110)
Browse files Browse the repository at this point in the history
  • Loading branch information
pantierra authored Nov 15, 2024
1 parent cd746cb commit a65f6a1
Show file tree
Hide file tree
Showing 9 changed files with 1,125 additions and 0 deletions.
12 changes: 12 additions & 0 deletions dockerfiles/Dockerfile.stac
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions runtimes/eoapi/stac/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## eoapi.stac

![](https://user-images.githubusercontent.com/10407788/151456592-f61ec158-c865-4d98-8d8b-ce05381e0e62.png)
3 changes: 3 additions & 0 deletions runtimes/eoapi/stac/eoapi/stac/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""eoapi.stac."""

__version__ = "0.1.0"
177 changes: 177 additions & 0 deletions runtimes/eoapi/stac/eoapi/stac/app.py
Original file line number Diff line number Diff line change
@@ -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=[])
44 changes: 44 additions & 0 deletions runtimes/eoapi/stac/eoapi/stac/config.py
Original file line number Diff line number Diff line change
@@ -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",
}
106 changes: 106 additions & 0 deletions runtimes/eoapi/stac/eoapi/stac/extension.py
Original file line number Diff line number Diff line change
@@ -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"])
30 changes: 30 additions & 0 deletions runtimes/eoapi/stac/eoapi/stac/logs.py
Original file line number Diff line number Diff line change
@@ -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,
},
}
)
Loading

0 comments on commit a65f6a1

Please sign in to comment.