diff --git a/docs/gavicore/models/api.md b/docs/gavicore/models/api.md index efbf6bf0..f2ef371c 100644 --- a/docs/gavicore/models/api.md +++ b/docs/gavicore/models/api.md @@ -33,9 +33,9 @@ ## `gavicore.models` - OGC Application Package and Workflow descriptions -::: gavicore.dru_models.OGCApplicationPackage +::: gavicore.dru_models.OgcApplicationPackage -::: gavicore.dru_models.OGCApplicationPackageProcessDescription +::: gavicore.dru_models.OgcApplicationPackageProcessDescription ::: gavicore.dru_models.CWLDescription diff --git a/docs/gavicore/service/api.md b/docs/gavicore/service/api.md index 2f1344a9..350afff0 100644 --- a/docs/gavicore/service/api.md +++ b/docs/gavicore/service/api.md @@ -2,4 +2,4 @@ ::: gavicore.service.Service -::: gavicore.dru_service.DRUService +::: gavicore.dru_service.DruService diff --git a/gavicore/src/gavicore/dru_models.py b/gavicore/src/gavicore/dru_models.py index bbbcd2ac..524a18c6 100644 --- a/gavicore/src/gavicore/dru_models.py +++ b/gavicore/src/gavicore/dru_models.py @@ -15,7 +15,7 @@ # --------------------------------------------------------------------- -class OGCApplicationPackage(BaseModel): +class OgcApplicationPackage(BaseModel): """ An OGC Application Package is a document that describes a process in sufficient detail so that an implementation of this Standard can @@ -25,25 +25,25 @@ class OGCApplicationPackage(BaseModel): For more information, see: /req/ogcapppkg/schema """ - process_description: OGCApplicationPackageProcessDescription | None = Field( + process_description: OgcApplicationPackageProcessDescription | None = Field( None, alias="processDescription" ) """Process description of a given process.""" - execution_uni: ExecutionUnitBase | list[ExecutionUnitBase] = Field( + execution_unit: ExecutionUnitBase | list[ExecutionUnitBase] = Field( alias="executionUnit" ) """The execution unit of process.""" -class OGCApplicationPackageProcessDescription(BaseModel): +class OgcApplicationPackageProcessDescription(BaseModel): """Wrapper around `ProcessDescription` to insert additional field name.""" process: ProcessDescription | None = None """The process description.""" -class CWLDescription(BaseModel): +class CwlDescription(BaseModel): """ Possible encoding of an execution unit as CWL. """ @@ -202,11 +202,11 @@ class GenericExecutionUnit(BaseModel): ExecutionUnitBase: TypeAlias = ( - Link | CWLDescription | ContainerImage | GenericExecutionUnit + Link | CwlDescription | ContainerImage | GenericExecutionUnit ) """Execution unit encoding of a process.""" ContainerBindings.model_rebuild() ExecutionUnitContainer.model_rebuild() ContainerImage.model_rebuild() -OGCApplicationPackage.model_rebuild() +OgcApplicationPackage.model_rebuild() diff --git a/gavicore/src/gavicore/dru_service.py b/gavicore/src/gavicore/dru_service.py index 5f61d8fb..2212a6ea 100644 --- a/gavicore/src/gavicore/dru_service.py +++ b/gavicore/src/gavicore/dru_service.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from typing import Optional -from .dru_models import OGCApplicationPackage +from .dru_models import OgcApplicationPackage from .models import ProcessSummary from .service import Service -class DRUService(Service, ABC): - """The DRUService interface extends the Service interface by providing +class DruService(Service, ABC): + """The DruService interface extends the Service interface by providing four endpoints defined per [OGC API - Processes — Part 2 (DRU)](https://docs.ogc.org/DRAFTS/20-044.html).""" @@ -70,7 +70,7 @@ async def undeploy_process(self, process_id: str, *args, **kwargs) -> None: @abstractmethod async def get_formal_description( self, process_id: str, *args, **kwargs - ) -> OGCApplicationPackage: + ) -> OgcApplicationPackage: """Retrieve a formal description of a previously deployed process via the deploy operation. The returned description relates to the most recent deployment. diff --git a/gavicore/tests/test_dru_models.py b/gavicore/tests/test_dru_models.py index 75e54dd2..5f2afa3e 100644 --- a/gavicore/tests/test_dru_models.py +++ b/gavicore/tests/test_dru_models.py @@ -11,9 +11,9 @@ import gavicore.dru_models as m REQUIRED_CLASSES = { - "OGCApplicationPackage", - "OGCApplicationPackageProcessDescription", - "CWLDescription", + "OgcApplicationPackage", + "OgcApplicationPackageProcessDescription", + "CwlDescription", "ContainerImage", "ExecutionUnitContainer", "ContainerConfig", diff --git a/gavicore/tests/test_dru_service.py b/gavicore/tests/test_dru_service.py index a732880a..7e0ebed4 100644 --- a/gavicore/tests/test_dru_service.py +++ b/gavicore/tests/test_dru_service.py @@ -5,7 +5,7 @@ import inspect from unittest import TestCase -from gavicore.dru_service import DRUService +from gavicore.dru_service import DruService from .test_service import REQUIRED_METHODS as REQUIRED_SERVICE_METHODS @@ -22,6 +22,6 @@ class DRUServiceTest(TestCase): def test_methods(self): all_method_names = set( - name for name, obj in inspect.getmembers(DRUService, inspect.isfunction) + name for name, obj in inspect.getmembers(DruService, inspect.isfunction) ) self.assertSetEqual(REQUIRED_DRU_METHODS, set(all_method_names)) diff --git a/wraptile/src/wraptile/ap_response.py b/wraptile/src/wraptile/ap_response.py new file mode 100644 index 00000000..0be56f27 --- /dev/null +++ b/wraptile/src/wraptile/ap_response.py @@ -0,0 +1,7 @@ +from fastapi.responses import JSONResponse + + +class OgcApplicationPackageResponse(JSONResponse): + """Custom response class to correctly incorporate content type in response.""" + + media_type = "application/ogcapppkg+json" diff --git a/wraptile/src/wraptile/app.py b/wraptile/src/wraptile/app.py index 11d10a51..3aee5d74 100644 --- a/wraptile/src/wraptile/app.py +++ b/wraptile/src/wraptile/app.py @@ -4,6 +4,7 @@ import logging import time +from contextlib import asynccontextmanager from typing import Awaitable, Callable from fastapi import FastAPI, Request, Response @@ -11,8 +12,17 @@ from fastapi.responses import JSONResponse from .exceptions import ServiceException +from .provider import get_service -app = FastAPI() + +@asynccontextmanager +async def load_app_eagerly(app: FastAPI): + get_service() # startup ... + yield # running ... + # shutdown ... + + +app = FastAPI(lifespan=load_app_eagerly) app.add_middleware( CORSMiddleware, allow_credentials=False, # we disallow Cookie-Auth (FastAPI default) diff --git a/wraptile/src/wraptile/dru_routes.py b/wraptile/src/wraptile/dru_routes.py new file mode 100644 index 00000000..03f5d9cb --- /dev/null +++ b/wraptile/src/wraptile/dru_routes.py @@ -0,0 +1,155 @@ +import fastapi + +from gavicore.dru_models import OgcApplicationPackage +from gavicore.dru_service import DruService +from gavicore.models import ApiError, ProcessSummary + +from .ap_response import OgcApplicationPackageResponse +from .provider import get_service + +dru_router = fastapi.APIRouter() + + +# noinspection PyPep8Naming +@dru_router.post( + "/processes", + response_model=ProcessSummary, + status_code=201, + responses={ + "202": {}, + "403": {"model": ApiError}, + "409": {"model": ApiError}, + "415": {"model": ApiError}, + "501": {"model": ApiError}, + }, + response_model_exclude_none=True, + response_model_exclude_unset=True, + openapi_extra={ + "requestBody": { + # NOTE: schemas of the request body below are kept abstract since swagger-ui resolves `$ref`, + # thus slowing down its web interface considerably. + # Querying the `openapi.json` directly does not result in slow downs. + "content": { + "application/cwl": { + "schema": { + "type": "object", + "additionalProperties": True, + } + }, + "application/cwl+json": { + "schema": { + "type": "object", + "additionalProperties": True, + } + }, + "application/cwl+yaml": { + "schema": { + "type": "object", + "additionalProperties": True, + } + }, + }, + "required": True, + } + }, +) +async def deploy_process( + request: fastapi.Request, + response: fastapi.Response, + w: str | None = None, + service: DruService = fastapi.Depends(get_service), # noqa B008 +): + return await service.deploy_process( + w=w, + request=request, + response=response, + ) + + +# noinspection PyPep8Naming +@dru_router.put( + "/processes/{processId}", + response_model=ProcessSummary, + responses={ + "201": {"model": ProcessSummary}, + "202": {"model": ProcessSummary}, + "204": {}, + "403": {"model": ApiError}, + "404": {"model": ApiError}, + "415": {"model": ApiError}, + # NOTE: use 501 for parts of the standard that are not implemented (yet) + "501": {"model": ApiError}, + }, + response_model_exclude_none=True, + response_model_exclude_unset=True, + openapi_extra={ + "requestBody": { + "content": { + "application/cwl": {"schema": ProcessSummary.model_json_schema()}, + "application/cwl+json": {"schema": ProcessSummary.model_json_schema()}, + "application/cwl+yaml": {"schema": ProcessSummary.model_json_schema()}, + }, + "required": True, + } + }, +) +async def replace_process( + processId: str, + request: fastapi.Request, + response: fastapi.Response, + w: str | None = None, + service: DruService = fastapi.Depends(get_service), # noqa B008 +): + return await service.replace_process( + process_id=processId, + w=w, + request=request, + response=response, + ) + + +# noinspection PyPep8Naming +@dru_router.delete( + "/processes/{processId}", + status_code=204, + responses={ + "403": {"model": ApiError}, + "404": {"model": ApiError}, + "501": {"model": ApiError}, + }, + response_model_exclude_none=True, + response_model_exclude_unset=True, +) +async def undeploy_process( + processId: str, + request: fastapi.Request, + response: fastapi.Response, + service: DruService = fastapi.Depends(get_service), # noqa B008 +): + return await service.undeploy_process( + process_id=processId, request=request, response=response + ) + + +# noinspection PyPep8Naming +@dru_router.get( + "/processes/{processId}/package", + response_model=OgcApplicationPackage, + response_class=OgcApplicationPackageResponse, + responses={ + "403": {"model": ApiError}, + "404": {"model": ApiError}, + "501": {"model": ApiError}, + }, + response_model_exclude_none=True, + response_model_exclude_unset=True, +) +async def get_formal_description( + processId: str, + request: fastapi.Request, + response: fastapi.Response, + service: DruService = fastapi.Depends(get_service), # noqa B008 +): + return await service.get_formal_description( + process_id=processId, request=request, response=response + ) diff --git a/wraptile/src/wraptile/services/base/service_base.py b/wraptile/src/wraptile/services/base/service_base.py index 052a58cc..47a0faea 100644 --- a/wraptile/src/wraptile/services/base/service_base.py +++ b/wraptile/src/wraptile/services/base/service_base.py @@ -6,12 +6,14 @@ import os import shlex from abc import ABC +from importlib import import_module from typing import Optional import fastapi import yaml from starlette.routing import Route +from gavicore.dru_service import DruService from gavicore.models import ( Capabilities, ConformanceDeclaration, @@ -92,6 +94,24 @@ def load(cls) -> "ServiceBase": name="service", example="path.to.module:service", ) + + if issubclass(service.__class__, DruService): + try: + app_module = import_module("wraptile.app") + app: fastapi.FastAPI = getattr(app_module, "app") + + route_module = import_module("wraptile.dru_routes") + router: fastapi.APIRouter = getattr(route_module, "dru_router") + except AttributeError: + raise ServiceConfigException( + "Unable to load additional DRU routes" + ) from None + else: + app.include_router(router) + + logging.getLogger("uvicorn").info( + "Loaded additional routes to support Deploy, Redeploy, Undeploy" + ) except (ValueError, TypeError) as e: raise ServiceConfigException(f"{e}") from e logger = logging.getLogger("uvicorn") diff --git a/wraptile/tests/test_ap_response.py b/wraptile/tests/test_ap_response.py new file mode 100644 index 00000000..368d473d --- /dev/null +++ b/wraptile/tests/test_ap_response.py @@ -0,0 +1,15 @@ +from unittest import TestCase + +from fastapi.responses import JSONResponse + +from wraptile.ap_response import OgcApplicationPackageResponse + + +class OgcApplicationPackageResponseTest(TestCase): + def test_subclasses_json_response(self): + self.assertTrue(issubclass(OgcApplicationPackageResponse, JSONResponse)) + + def test_media_type(self): + self.assertEqual( + OgcApplicationPackageResponse.media_type, "application/ogcapppkg+json" + )