Skip to content
Open
4 changes: 2 additions & 2 deletions docs/gavicore/models/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/gavicore/service/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

::: gavicore.service.Service

::: gavicore.dru_service.DRUService
::: gavicore.dru_service.DruService
18 changes: 10 additions & 8 deletions gavicore/src/gavicore/dru_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
"""
Expand All @@ -52,6 +52,8 @@ class CWLDescription(BaseModel):
"""Media type used to identify the execution unit type.
Must always be 'application/cwl'."""

# NOTE: field must follow CWL schema, which cannot easily
# be converted to Pydantic model.
value: str | None = None
"""JSON-encoded CWL document."""

Expand Down Expand Up @@ -118,7 +120,7 @@ class ContainerBindings(BaseModel):
"""Output bindings."""


class InputBinding(BaseModel):
class InputBinding(OgcBaseModel):
"""Defines how to specify the input for the execution unit.

- The value of various properties defined below can be expressions.
Expand Down Expand Up @@ -202,11 +204,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()
8 changes: 4 additions & 4 deletions gavicore/src/gavicore/dru_service.py
Original file line number Diff line number Diff line change
@@ -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)."""

Expand Down Expand Up @@ -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.
Expand Down
49 changes: 45 additions & 4 deletions gavicore/tests/test_dru_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
# https://opensource.org/license/apache-2-0.

import inspect
from typing import TypeVar
from typing import Any, TypeVar
from unittest import TestCase

from pydantic import BaseModel

import gavicore.dru_models as m

REQUIRED_CLASSES = {
"OGCApplicationPackage",
"OGCApplicationPackageProcessDescription",
"CWLDescription",
"OgcApplicationPackage",
"OgcApplicationPackageProcessDescription",
"CwlDescription",
"ContainerImage",
"ExecutionUnitContainer",
"ContainerConfig",
Expand Down Expand Up @@ -43,3 +43,44 @@ def test_models_have_repr_json(self):
for name, obj in inspect.getmembers(m, inspect.isclass):
if name in REQUIRED_CLASSES and issubclass(obj, BaseModel):
self.assertTrue(hasattr(obj, "_repr_json_"), msg=f"model {name}")

def test_models_with_extensions(self):
execution_unit_container = self._assert_extendable_model(
m.ExecutionUnitContainer,
{
"image": "ghcr.io/osgeo/gdal:alpine-normal-latest-amd64",
"x-placeholder": ["list", "of", "extra", "values"],
},
)
self.assertEqual(
["list", "of", "extra", "values"],
execution_unit_container.model_extra.get("x-placeholder"),
)

container_config = self._assert_extendable_model(
m.ContainerConfig, {"x-placeholder": {"gpu_config": {"vendor": "nvidia"}}}
)
self.assertEqual(
{"gpu_config": {"vendor": "nvidia"}},
container_config.model_extra.get("x-placeholder"),
)

input_binding = self._assert_extendable_model(
m.InputBinding, {"x-placeholder": "literal value"}
)
self.assertEqual(
"literal value", input_binding.model_extra.get("x-placeholder")
)

output_binding = self._assert_extendable_model(
m.OutputBinding, {"x-placeholder": 13}
)
self.assertEqual(13, output_binding.model_extra.get("x-placeholder"))

def _assert_extendable_model(self, model_cls: type[T], data: dict[str, Any]) -> T:
model_instance = model_cls(**data)
self.assertEqual(
data,
model_instance.model_dump(mode="json", by_alias=True, exclude_unset=True),
)
return model_instance
4 changes: 2 additions & 2 deletions gavicore/tests/test_dru_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))
7 changes: 7 additions & 0 deletions wraptile/src/wraptile/ap_response.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 11 additions & 1 deletion wraptile/src/wraptile/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@

import logging
import time
from contextlib import asynccontextmanager
from typing import Awaitable, Callable

from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
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)
Expand Down
155 changes: 155 additions & 0 deletions wraptile/src/wraptile/dru_routes.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading