generated from EOEPCA/document-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added custom runtime for eoAPI stac component. (#110)
- Loading branch information
Showing
9 changed files
with
1,125 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
"""eoapi.stac.""" | ||
|
||
__version__ = "0.1.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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=[]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
} | ||
) |
Oops, something went wrong.