diff --git a/infrastructure/app.py b/infrastructure/app.py index 27ce1d6..986071d 100644 --- a/infrastructure/app.py +++ b/infrastructure/app.py @@ -175,12 +175,12 @@ def __init__( path=os.path.abspath(context_dir), file="infrastructure/dockerfiles/Dockerfile.raster", build_args={ - "PYTHON_VERSION": "3.11", + "PYTHON_VERSION": "3.12", }, platform="linux/amd64", ), "handler": "handler.handler", - "runtime": aws_lambda.Runtime.PYTHON_3_11, + "runtime": aws_lambda.Runtime.PYTHON_3_12, }, ) @@ -224,12 +224,12 @@ def __init__( path=os.path.abspath(context_dir), file="infrastructure/dockerfiles/Dockerfile.stac", build_args={ - "PYTHON_VERSION": "3.11", + "PYTHON_VERSION": "3.12", }, platform="linux/amd64", ), "handler": "handler.handler", - "runtime": aws_lambda.Runtime.PYTHON_3_11, + "runtime": aws_lambda.Runtime.PYTHON_3_12, }, ) @@ -271,12 +271,12 @@ def __init__( path=os.path.abspath(context_dir), file="infrastructure/dockerfiles/Dockerfile.vector", build_args={ - "PYTHON_VERSION": "3.11", + "PYTHON_VERSION": "3.12", }, platform="linux/amd64", ), "handler": "handler.handler", - "runtime": aws_lambda.Runtime.PYTHON_3_11, + "runtime": aws_lambda.Runtime.PYTHON_3_12, }, ) diff --git a/infrastructure/dockerfiles/Dockerfile.raster b/infrastructure/dockerfiles/Dockerfile.raster index 16c16c2..1611a4f 100644 --- a/infrastructure/dockerfiles/Dockerfile.raster +++ b/infrastructure/dockerfiles/Dockerfile.raster @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.11 +ARG PYTHON_VERSION=3.12 FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} diff --git a/infrastructure/dockerfiles/Dockerfile.stac b/infrastructure/dockerfiles/Dockerfile.stac index def8886..c219abc 100644 --- a/infrastructure/dockerfiles/Dockerfile.stac +++ b/infrastructure/dockerfiles/Dockerfile.stac @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.11 +ARG PYTHON_VERSION=3.12 FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} diff --git a/infrastructure/dockerfiles/Dockerfile.vector b/infrastructure/dockerfiles/Dockerfile.vector index 176d37f..1e1ee18 100644 --- a/infrastructure/dockerfiles/Dockerfile.vector +++ b/infrastructure/dockerfiles/Dockerfile.vector @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.11 +ARG PYTHON_VERSION=3.12 FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} diff --git a/infrastructure/handlers/stac_handler.py b/infrastructure/handlers/stac_handler.py index fd6245d..b7f763e 100644 --- a/infrastructure/handlers/stac_handler.py +++ b/infrastructure/handlers/stac_handler.py @@ -5,18 +5,24 @@ import os from eoapi.stac.app import app -from eoapi.stac.config import PostgresSettings +from eoapi.stac.config import PostgresSettings, Settings from mangum import Mangum from stac_fastapi.pgstac.db import connect_to_db logging.getLogger("mangum.lifespan").setLevel(logging.ERROR) logging.getLogger("mangum.http").setLevel(logging.ERROR) +settings = Settings() + @app.on_event("startup") async def startup_event() -> None: """Connect to database on startup.""" - await connect_to_db(app, postgres_settings=PostgresSettings()) + await connect_to_db( + app, + postgres_settings=PostgresSettings(), + add_write_connection_pool=settings.enable_transaction, + ) handler = Mangum(app, lifespan="off") diff --git a/runtimes/eoapi/raster/eoapi/raster/app.py b/runtimes/eoapi/raster/eoapi/raster/app.py index 95acc00..40e66c0 100644 --- a/runtimes/eoapi/raster/eoapi/raster/app.py +++ b/runtimes/eoapi/raster/eoapi/raster/app.py @@ -1,9 +1,8 @@ """eoAPI Raster application.""" import logging -import re from contextlib import asynccontextmanager -from typing import Dict +from typing import Annotated, Dict, Literal, Optional import jinja2 import pystac @@ -28,6 +27,9 @@ TMSFactory, ) from titiler.core.middleware import CacheControlMiddleware +from titiler.core.models.OGC import Conformance, Landing +from titiler.core.resources.enums import MediaType +from titiler.core.utils import accept_media_type, create_html_response, update_openapi from titiler.extensions import cogViewerExtension from titiler.mosaic.errors import MOSAIC_STATUS_CODES from titiler.pgstac import __version__ as titiler_pgstac_version @@ -77,11 +79,13 @@ logger.debug("Loading jinja2 templates...") jinja2_env = jinja2.Environment( + autoescape=jinja2.select_autoescape(["html", "xml"]), loader=jinja2.ChoiceLoader( [ jinja2.PackageLoader(__package__, "templates"), + jinja2.PackageLoader("titiler.core", "templates"), ] - ) + ), ) templates = Jinja2Templates(env=jinja2_env) @@ -112,6 +116,10 @@ async def lifespan(app: FastAPI): "usePkceWithAuthorizationCodeGrant": auth_settings.use_pkce, }, ) + +# Fix OpenAPI response header for OGC Common compatibility +update_openapi(app) + add_exception_handlers(app, DEFAULT_STATUS_CODES) add_exception_handlers(app, MOSAIC_STATUS_CODES) @@ -140,6 +148,14 @@ async def lifespan(app: FastAPI): }, ) +TITILER_CONFORMS_TO = { + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", +} + ############################################################################### # `Secret` endpoint for mosaic builder. Do not need to be public (in the OpenAPI docs) @@ -164,6 +180,7 @@ async def list_collection(request: Request): extensions=[ searchInfoExtension(), ], + templates=templates, ) app.include_router( searches.router, tags=["STAC Search"], prefix="/searches/{search_id}" @@ -176,16 +193,16 @@ async def list_collection(request: Request): searches.layer_dependency, searches.dataset_dependency, searches.pixel_selection_dependency, - searches.tile_dependency, searches.process_dependency, searches.render_dependency, - searches.pgstac_dependency, + searches.assets_accessor_dependency, searches.reader_dependency, searches.backend_dependency, ], tags=["STAC Search"], ) add_search_list_route(app, prefix="/searches", tags=["STAC Search"]) +TITILER_CONFORMS_TO.update(searches.conforms_to) @app.get("/searches/builder", response_class=HTMLResponse, tags=["STAC Search"]) @@ -218,11 +235,12 @@ async def virtual_mosaic_builder(request: Request): extensions=[ searchInfoExtension(), ], + templates=templates, ) app.include_router( collection.router, tags=["STAC Collection"], prefix="/collections/{collection_id}" ) - +TITILER_CONFORMS_TO.update(collection.conforms_to) ############################################################################### # STAC Item Endpoints @@ -231,11 +249,7 @@ async def virtual_mosaic_builder(request: Request): path_dependency=ItemIdParams, router_prefix="/collections/{collection_id}/items/{item_id}", add_viewer=True, -) -app.include_router( - stac.router, - tags=["STAC Item"], - prefix="/collections/{collection_id}/items/{item_id}", + templates=templates, ) @@ -260,6 +274,7 @@ def viewer(request: Request, item: pystac.Item = Depends(stac.path_dependency)): tags=["STAC Item"], prefix="/collections/{collection_id}/items/{item_id}", ) +TITILER_CONFORMS_TO.update(stac.conforms_to) ############################################################################### @@ -268,12 +283,15 @@ def viewer(request: Request, item: pystac.Item = Depends(stac.path_dependency)): path_dependency=AssetIdParams, router_prefix="/collections/{collection_id}/items/{item_id}/assets/{asset_id}", add_viewer=True, + templates=templates, ) app.include_router( asset.router, tags=["STAC Asset"], prefix="/collections/{collection_id}/items/{item_id}/assets/{asset_id}", ) +TITILER_CONFORMS_TO.update(asset.conforms_to) + ############################################################################### # External Dataset Endpoints @@ -282,30 +300,32 @@ def viewer(request: Request, item: pystac.Item = Depends(stac.path_dependency)): extensions=[ cogViewerExtension(), ], + templates=templates, ) app.include_router( external_cog.router, tags=["External Dataset"], prefix="/external", ) +TITILER_CONFORMS_TO.update(external_cog.conforms_to) ############################################################################### # Tiling Schemes Endpoints -tms = TMSFactory() +tms = TMSFactory(templates=templates) app.include_router(tms.router, tags=["Tiling Schemes"]) +TITILER_CONFORMS_TO.update(tms.conforms_to) ############################################################################### # Algorithms Endpoints -algorithms = AlgorithmFactory() +algorithms = AlgorithmFactory(templates=templates) app.include_router(algorithms.router, tags=["Algorithms"]) +TITILER_CONFORMS_TO.update(algorithms.conforms_to) ############################################################################### # Colormaps endpoints -cmaps = ColorMapFactory() -app.include_router( - cmaps.router, - tags=["ColorMaps"], -) +cmaps = ColorMapFactory(templates=templates) +app.include_router(cmaps.router, tags=["ColorMaps"]) +TITILER_CONFORMS_TO.update(cmaps.conforms_to) ############################################################################### @@ -341,13 +361,30 @@ def ping( # Landing page Endpoint @app.get( "/", - response_class=HTMLResponse, - tags=["Landing"], + response_model=Landing, + response_model_exclude_none=True, + responses={ + 200: { + "content": { + "text/html": {}, + "application/json": {}, + } + }, + }, + tags=["OGC Common"], ) -def landing(request: Request): - """Get landing page.""" +def landing( + request: Request, + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, +): + """TiTiler landing page.""" data = { - "title": settings.name or "eoAPI-raster", + "title": settings.name or "eoAPI-Raster", "links": [ { "title": "Landing page", @@ -367,6 +404,12 @@ def landing(request: Request): "type": "text/html", "rel": "service-doc", }, + { + "title": "Conformance Declaration", + "href": str(request.url_for("conformance")), + "type": "text/html", + "rel": "http://www.opengis.net/def/rel/ogc/1.0/conformance", + }, { "title": "eoAPI Virtual Mosaic list (JSON)", "href": str(app.url_path_for("list_searches")), @@ -419,42 +462,112 @@ def landing(request: Request): "rel": "data", "templated": True, }, + { + "title": "List of Available TileMatrixSets", + "href": str(request.url_for("tilematrixsets")), + "type": "application/json", + "rel": "http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes", + }, + { + "title": "List of Available Algorithms", + "href": str(request.url_for("available_algorithms")), + "type": "application/json", + "rel": "data", + }, + { + "title": "List of Available ColorMaps", + "href": str(request.url_for("available_colormaps")), + "type": "application/json", + "rel": "data", + }, + { + "title": "TiTiler-PgSTAC Documentation (external link)", + "href": "https://stac-utils.github.io/titiler-pgstac/", + "type": "text/html", + "rel": "doc", + }, + { + "title": "TiTiler-PgSTAC source code (external link)", + "href": "https://github.com/stac-utils/titiler-pgstac", + "type": "text/html", + "rel": "doc", + }, ], } - urlpath = request.url.path - if root_path := request.app.root_path: - urlpath = re.sub(r"^" + root_path, "", urlpath) - crumbs = [] - baseurl = str(request.base_url).rstrip("/") - - crumbpath = str(baseurl) - 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()}) + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) - return templates.TemplateResponse( - request, - name="landing.html", - context={ - "request": request, - "response": data, - "template": { - "api_root": baseurl, - "params": request.query_params, - "title": "TiTiler-PgSTAC", - }, - "crumbs": crumbs, - "url": str(request.url), - "baseurl": baseurl, - "urlpath": str(request.url.path), - "urlparams": str(request.url.query), + if output_type == MediaType.html: + return create_html_response( + request, + data, + "landing", + title="eoAPI-raster", + templates=templates, + ) + + return data + + +@app.get( + "/conformance", + response_model=Conformance, + response_model_exclude_none=True, + responses={ + 200: { + "content": { + "text/html": {}, + "application/json": {}, + } }, - ) + }, + tags=["OGC Common"], +) +def conformance( + request: Request, + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, +): + """Conformance classes. + + Called with `GET /conformance`. + + Returns: + Conformance classes which the server conforms to. + + """ + data = {"conformsTo": sorted(TITILER_CONFORMS_TO)} + + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data, + "conformance", + title="Conformance", + templates=templates, + ) + + return data # Add dependencies to routes diff --git a/runtimes/eoapi/raster/eoapi/raster/templates/conformance.html b/runtimes/eoapi/raster/eoapi/raster/templates/conformance.html new file mode 100644 index 0000000..0471b36 --- /dev/null +++ b/runtimes/eoapi/raster/eoapi/raster/templates/conformance.html @@ -0,0 +1,32 @@ +{% include "header.html" %} +{% if params %} + {% set urlq = url + '?' + params + '&' %} + {% else %} + {% set urlq = url + '?' %} +{% endif %} + + + +
This API implements the conformance classes from standards and community specifications that are listed below.
+ +