From 46e334b41ff8a2cb33fa01f8740d179d69c07cb6 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Fri, 2 Oct 2026 11:05:40 +0200 Subject: [PATCH 01/14] feat(api): add inbound status notification callbacks and problem details handling Implement inbound event-driven status notification endpoints under /unit_jobs/{job_id}/ to receive CloudEvents from execution runners, map stages to APEx processing statuses, update the database, and return RFC 7807 problem details. - Add dependencies eoap-problems-registry[fastapi] and stage-events-client to requirements.txt. - Add DISPATCH_API_PUBLIC_URL setting in app/config/settings.py and env.example. - Register problem_registry_exception_handler in app/middleware/error_handling.py to produce application/problem+json responses for RFC 7807 errors. - Add CloudEvent schemas, response models, and STAGE_TO_APEX_STATUS mapping in app/schemas/callbacks.py. - Implement get_job_by_platform_or_id and update_job_status_from_callback in app/services/processing.py without altering database models. - Implement POST /unit_jobs/{job_id}/success, /inProgress, and /failed endpoints in app/routers/unit_jobs.py returning 202 Accepted. - Surgically integrate callback entry points and component schemas into openapi.json. - Add unit tests covering 202 status transitions and 404 RFC 7807 responses in tests/routers/test_unit_jobs_callbacks.py. --- app/config/settings.py | 4 + app/middleware/error_handling.py | 24 +++ app/routers/unit_jobs.py | 153 +++++++++++++- app/schemas/callbacks.py | 136 +++++++++++++ app/services/processing.py | 39 ++++ env.example | 1 + openapi.json | 165 ++++++++++++++- requirements.txt | 2 + tests/routers/test_unit_jobs_callbacks.py | 233 ++++++++++++++++++++++ 9 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 app/schemas/callbacks.py create mode 100644 tests/routers/test_unit_jobs_callbacks.py diff --git a/app/config/settings.py b/app/config/settings.py index 308b651..a30cd57 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -24,6 +24,10 @@ class Settings(BaseSettings): cors_allowed_origins: str = Field( default="", json_schema_extra={"env": "CORS_ALLOWED_ORIGINS"} ) + dispatch_api_public_url: str = Field( + default="http://localhost:8000", + json_schema_extra={"env": "DISPATCH_API_PUBLIC_URL"}, + ) model_config = SettingsConfigDict( env_file=".env", diff --git a/app/middleware/error_handling.py b/app/middleware/error_handling.py index b63b4e8..f46dc9b 100644 --- a/app/middleware/error_handling.py +++ b/app/middleware/error_handling.py @@ -1,6 +1,8 @@ +import json import traceback from typing import Any +from eoap_problems_registry.fastapi import ProblemRegistryException from fastapi import Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -78,11 +80,33 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) +async def problem_registry_exception_handler( + request: Request, exc: ProblemRegistryException +): + request_id = correlation_id_ctx.get() + logger.error( + f"ProblemRegistryException raised (request_id={request_id}): " + f"status={exc.status_code}, detail={exc.detail}" + ) + try: + content = json.loads(exc.detail) + except Exception: + content = {"detail": exc.detail} + + return JSONResponse( + status_code=exc.status_code, + content=content, + media_type="application/problem+json", + headers=exc.headers, + ) + + def register_exception_handlers(app): """ Call this in main.py after creating the FastAPI() instance. """ + app.add_exception_handler(ProblemRegistryException, problem_registry_exception_handler) app.add_exception_handler(DispatcherException, dispatch_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(Exception, generic_exception_handler) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index 5770746..cfae265 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -12,7 +12,24 @@ JobNotFoundException, ) from app.middleware.error_handling import get_dispatcher_error_response -from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum +from eoap_problems_registry import NotFound, ServerError +from eoap_problems_registry.fastapi import ProblemRegistryException +from stage_events_client.models import ( + CompletedCloudEvent, + DismissedCloudEvent, + FailedCloudEvent, + PipedCloudEvent, + PreparedCloudEvent, + StagedCloudEvent, + SubmittedCloudEvent, +) +from app.schemas.callbacks import ( + STAGE_TO_APEX_STATUS, + UnitJobsJobIdFailedPostResponse, + UnitJobsJobIdInProgressPostResponse, + UnitJobsJobIdSuccessPostResponse, +) +from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum, ProcessingStatusEnum from app.schemas.unit_job import ( BaseJobRequest, ProcessingJob, @@ -24,6 +41,7 @@ delete_processing_job, get_processing_job_by_user_id, get_processing_job_results, + update_job_status_from_callback, ) from stac_pydantic import Collection @@ -272,3 +290,136 @@ async def delete_job( message="An error occurred while deleting the processing job.", details={"error": str(e)} ) + + +@router.post( + "/unit_jobs/{job_id}/success", + response_model=None, + status_code=status.HTTP_202_ACCEPTED, + tags=["Unit Jobs"], + summary="URIs for success callbacks for jobs.", + description="The callback URL will be invoked after a job succeeded its execution.\n", + operation_id="onSuccessJob", + responses={ + status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdSuccessPostResponse}, + status.HTTP_404_NOT_FOUND: {"model": NotFound}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + }, +) +async def on_success_job( + job_id: str, + body: PipedCloudEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for success callbacks for jobs.""" + try: + job = update_job_status_from_callback( + db, job_id, ProcessingStatusEnum.FINISHED + ) + if not job: + raise ProblemRegistryException( + problem=NotFound( + instance=f"/unit_jobs/{job_id}/success", + ) + ) + except ProblemRegistryException: + raise + except Exception as e: + logger.error(f"Error handling success callback for job {job_id}: {e}") + raise ProblemRegistryException( + problem=ServerError( + instance=f"/unit_jobs/{job_id}/success", + ) + ) + + +@router.post( + "/unit_jobs/{job_id}/inProgress", + response_model=None, + status_code=status.HTTP_202_ACCEPTED, + tags=["Unit Jobs"], + summary="URIs for progress callbacks for jobs.", + description=( + "The callback URL will be invoked while a job is changing " + "its status during its execution.\n" + ), + operation_id="onProgressJob", + responses={ + status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdInProgressPostResponse}, + status.HTTP_404_NOT_FOUND: {"model": NotFound}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + }, +) +async def on_progress_job( + job_id: str, + body: ( + SubmittedCloudEvent + | DismissedCloudEvent + | PreparedCloudEvent + | CompletedCloudEvent + | StagedCloudEvent + ), + db: Session = Depends(get_db), +) -> None: + """URIs for progress callbacks for jobs.""" + try: + target_status = STAGE_TO_APEX_STATUS.get( + body.type, ProcessingStatusEnum.RUNNING + ) + job = update_job_status_from_callback(db, job_id, target_status) + if not job: + raise ProblemRegistryException( + problem=NotFound( + instance=f"/unit_jobs/{job_id}/inProgress", + ) + ) + except ProblemRegistryException: + raise + except Exception as e: + logger.error(f"Error handling inProgress callback for job {job_id}: {e}") + raise ProblemRegistryException( + problem=ServerError( + instance=f"/unit_jobs/{job_id}/inProgress", + ) + ) + + +@router.post( + "/unit_jobs/{job_id}/failed", + response_model=None, + status_code=status.HTTP_202_ACCEPTED, + tags=["Unit Jobs"], + summary="URIs for failed callbacks for jobs.", + description="The callback URL will be invoked after a job failed its execution.\n", + operation_id="onFailedJob", + responses={ + status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdFailedPostResponse}, + status.HTTP_404_NOT_FOUND: {"model": NotFound}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + }, +) +async def on_failed_job( + job_id: str, + body: FailedCloudEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for failed callbacks for jobs.""" + try: + job = update_job_status_from_callback( + db, job_id, ProcessingStatusEnum.FAILED + ) + if not job: + raise ProblemRegistryException( + problem=NotFound( + instance=f"/unit_jobs/{job_id}/failed", + ) + ) + except ProblemRegistryException: + raise + except Exception as e: + logger.error(f"Error handling failed callback for job {job_id}: {e}") + raise ProblemRegistryException( + problem=ServerError( + instance=f"/unit_jobs/{job_id}/failed", + ) + ) diff --git a/app/schemas/callbacks.py b/app/schemas/callbacks.py new file mode 100644 index 0000000..e0de6e3 --- /dev/null +++ b/app/schemas/callbacks.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from pydantic import RootModel +from eoap_problems_registry import ( + BadRequest, + InvalidBodyPropertyFormat, + InvalidBodyPropertyValue, + InvalidParameters, + InvalidRequestHeaderFormat, + InvalidRequestParameterFormat, + InvalidRequestParameterValue, + MissingBodyProperty, + MissingRequestHeader, + MissingRequestParameter, +) +from stage_events_client.models import ( + SubmittedCloudEvent, + DismissedCloudEvent, + PreparedCloudEvent, + CompletedCloudEvent, + StagedCloudEvent, +) + +from app.schemas.enum import ProcessingStatusEnum + +STAGE_TO_APEX_STATUS: dict[str, ProcessingStatusEnum] = { + "prepared": ProcessingStatusEnum.QUEUED, + "submitted": ProcessingStatusEnum.CREATED, + "staged": ProcessingStatusEnum.RUNNING, + "piped": ProcessingStatusEnum.FINISHED, + "completed": ProcessingStatusEnum.FINISHED, + "failed": ProcessingStatusEnum.FAILED, + "dismissed": ProcessingStatusEnum.CANCELED, +} + + +class UnitJobsJobIdSuccessPostResponse( + RootModel[ + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ] +): + root: ( + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ) + + +class UnitJobsJobIdInProgressPostRequest( + RootModel[ + SubmittedCloudEvent + | DismissedCloudEvent + | PreparedCloudEvent + | CompletedCloudEvent + | StagedCloudEvent + ] +): + root: ( + SubmittedCloudEvent + | DismissedCloudEvent + | PreparedCloudEvent + | CompletedCloudEvent + | StagedCloudEvent + ) + + +class UnitJobsJobIdInProgressPostResponse( + RootModel[ + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ] +): + root: ( + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ) + + +class UnitJobsJobIdFailedPostResponse( + RootModel[ + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ] +): + root: ( + BadRequest + | InvalidBodyPropertyFormat + | InvalidBodyPropertyValue + | InvalidParameters + | InvalidRequestHeaderFormat + | InvalidRequestParameterFormat + | InvalidRequestParameterValue + | MissingBodyProperty + | MissingRequestHeader + | MissingRequestParameter + ) diff --git a/app/services/processing.py b/app/services/processing.py index 39b4f87..75f3fe4 100644 --- a/app/services/processing.py +++ b/app/services/processing.py @@ -6,9 +6,11 @@ from sqlalchemy.orm import Session from stac_pydantic import Collection + from app.auth import get_current_user_id from app.database.models.processing_job import ( ProcessingJobRecord, + get_job_by_id, get_job_by_user_id, get_jobs_by_user_id, remove_job_by_id, @@ -144,6 +146,43 @@ async def _refresh_job_status( return record +def get_job_by_platform_or_id( + database: Session, job_identifier: str +) -> Optional[ProcessingJobRecord]: + logger.info(f"Retrieving processing job with identifier {job_identifier}") + if job_identifier.isdigit(): + return get_job_by_id(database, int(job_identifier)) + + suffix = job_identifier.split(":")[-1] + return ( + database.query(ProcessingJobRecord) + .filter( + (ProcessingJobRecord.platform_job_id == job_identifier) + | (ProcessingJobRecord.platform_job_id == suffix) + | (ProcessingJobRecord.platform_job_id.endswith(f":{suffix}")) + ) + .first() + ) + + +def update_job_status_from_callback( + database: Session, job_identifier: str, status: ProcessingStatusEnum +) -> Optional[ProcessingJobRecord]: + logger.info( + f"Updating status of job '{job_identifier}' from callback to {status}" + ) + job = get_job_by_platform_or_id(database, job_identifier) + if not job: + logger.warning( + f"Job '{job_identifier}' not found for status update callback" + ) + return None + + update_job_status_by_id(database, job.id, status) + job.status = status + return job + + async def get_processing_jobs_by_user_id( token: str, database: Session, upscaling_task_id: int | None = None ) -> List[ProcessingJobSummary]: diff --git a/env.example b/env.example index 7d0762e..15a036d 100644 --- a/env.example +++ b/env.example @@ -6,6 +6,7 @@ APP_ENV= # CORS CORS_ALLOWED_ORIGINS= +DISPATCH_API_PUBLIC_URL= # Keycloak KEYCLOAK_HOST= diff --git a/openapi.json b/openapi.json index eafe5d0..885966e 100644 --- a/openapi.json +++ b/openapi.json @@ -711,6 +711,93 @@ } } } + }, + "/unit_jobs/{job_id}/success": { + "post": { + "operationId": "onSuccessJob", + "summary": "URIs for success callbacks for jobs.", + "description": "The callback URL will be invoked after a job succeeded its execution.\n", + "parameters": [ + { + "$ref": "#/components/parameters/job_id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/SuccessRequestBody" + }, + "responses": { + "202": { + "$ref": "#/components/responses/Accepted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/unit_jobs/{job_id}/inProgress": { + "post": { + "operationId": "onProgressJob", + "summary": "URIs for progress callbacks for jobs.", + "description": "The callback URL will be invoked while a job is changing its status during its execution.\n", + "parameters": [ + { + "$ref": "#/components/parameters/job_id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/InProgressRequestBody" + }, + "responses": { + "202": { + "$ref": "#/components/responses/Accepted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } + }, + "/unit_jobs/{job_id}/failed": { + "post": { + "operationId": "onFailedJob", + "summary": "URIs for failed callbacks for jobs.", + "description": "The callback URL will be invoked after a job failed its execution.\n", + "parameters": [ + { + "$ref": "#/components/parameters/job_id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/FailedRequestBody" + }, + "responses": { + "202": { + "$ref": "#/components/responses/Accepted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/ServerError" + } + } + } } }, "components": { @@ -2545,6 +2632,82 @@ } } } + }, + "parameters": { + "job_id": { + "name": "job_id", + "in": "path", + "description": "local identifier of a Job", + "required": true, + "schema": { + "type": "string" + } + } + }, + "requestBodies": { + "SuccessRequestBody": { + "description": "A structured-mode CloudEvent request body for successes.", + "required": true, + "content": { + "application/cloudevents+json": { + "schema": { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/PipedCloudEvent" + } + } + } + }, + "InProgressRequestBody": { + "description": "A structured-mode CloudEvent request body for failures.", + "required": true, + "content": { + "application/cloudevents+json": { + "schema": { + "oneOf": [ + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/SubmittedCloudEvent" + }, + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/DismissedCloudEvent" + }, + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/PreparedCloudEvent" + }, + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/CompletedCloudEvent" + }, + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/StagedCloudEvent" + } + ] + } + } + } + }, + "FailedRequestBody": { + "description": "A structured-mode CloudEvent request body for failures.", + "required": true, + "content": { + "application/cloudevents+json": { + "schema": { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/FailedCloudEvent" + } + } + } + } + }, + "responses": { + "Accepted": { + "description": "Request has been accepted for processing, but processing will be asychronously executed.\n" + }, + "BadRequest": { + "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/BadRequest" + }, + "NotFound": { + "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/NotFound" + }, + "ServerError": { + "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/ServerError" + } } } -} \ No newline at end of file +} diff --git a/requirements.txt b/requirements.txt index ef520c2..5e75d6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,5 @@ types-requests types-shapely uvicorn[standard] ogc-api-processes-client==0.6.0 +eoap-problems-registry[fastapi]>=1.5.0 +stage-events-client>=1.0.5 diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py new file mode 100644 index 0000000..32b675a --- /dev/null +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -0,0 +1,233 @@ +from unittest.mock import MagicMock, patch +import pytest +from fastapi import status + +from app.schemas.enum import OutputFormatEnum, ProcessingStatusEnum +from app.schemas.unit_job import ServiceDetails +from app.platforms.implementations.ogc_api_process import OGCAPIProcessPlatform + + +# --- Test Payloads --- +PIPED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "piped", + "data": { + "namespace": "default", + "process_id": "test-proc", + "job_id": "job-123", + "entries": {}, + }, +} + +STAGED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "staged", + "data": { + "namespace": "default", + "process_id": "test-proc", + "job_id": "job-123", + "outputs": {}, + }, +} + +PREPARED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "prepared", + "data": { + "namespace": "default", + "process_id": "test-proc", + "job_id": "job-123", + "inputs": {}, + }, +} + +SUBMITTED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "submitted", + "data": { + "namespace": "default", + "process_id": "test-proc", + }, +} + +DISMISSED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "dismissed", + "data": { + "namespace": "default", + "status": "Pending", + }, +} + +FAILED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "job:step:1", + "partitionkey": "part-1", + "type": "failed", + "data": { + "namespace": "default", + "process_id": "test-proc", + "job_id": "job-123", + "usage": {}, + }, +} + + +# --- Inbound Callback Tests --- + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_success_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/success", + json=PIPED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + mock_update.assert_called_once() + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.FINISHED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_success_404_problem_details(mock_update, client): + mock_update.return_value = None + r = client.post( + "/unit_jobs/nonexistent/success", + json=PIPED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_404_NOT_FOUND + assert r.headers.get("content-type") == "application/problem+json" + data = r.json() + assert data["status"] == 404 + assert data["title"] == "Not Found" + assert data["instance"] == "/unit_jobs/nonexistent/success" + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_in_progress_staged_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/inProgress", + json=STAGED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.RUNNING + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_in_progress_prepared_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/inProgress", + json=PREPARED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.QUEUED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_in_progress_submitted_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/inProgress", + json=SUBMITTED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.CREATED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_in_progress_dismissed_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/inProgress", + json=DISMISSED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.CANCELED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_failed_202(mock_update, client): + r = client.post( + "/unit_jobs/job-123/failed", + json=FAILED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_202_ACCEPTED + args, _ = mock_update.call_args + assert args[1] == "job-123" + assert args[2] == ProcessingStatusEnum.FAILED + + +# --- Outbound Subscriber Payload Test --- + +@pytest.mark.asyncio +@patch("app.platforms.implementations.ogc_api_process.exchange_token") +@patch("app.platforms.implementations.ogc_api_process.get_current_user_claims") +async def test_ogc_platform_execute_job_injects_subscriber( + mock_claims, mock_exchange, monkeypatch +): + mock_exchange.return_value = "fake_exchanged_token" + mock_claims.return_value = { + "sub": "user-sub", + "preferred_username": "testuser", + "email": "test@example.com", + } + + platform = OGCAPIProcessPlatform() + + mock_client = MagicMock() + mock_content = MagicMock() + mock_content.job_id = "ogc-exec-999" + mock_client.execute_simple.return_value = mock_content + + with patch.object( + platform, "_create_api_client_instance", return_value=mock_client + ), patch.object( + platform, "_transform_parameters", return_value={"param1": "val1"} + ): + details = ServiceDetails( + endpoint="https://ades.example.com", + application="my-app", + namespace="my-ns", + ) + job_id = await platform.execute_job( + user_token="user_token_123", + title="My Execution", + details=details, + parameters={"param1": "val1"}, + format=OutputFormatEnum.JSON, + ) + + assert job_id == "my-ns:ogc-exec-999" + + # Verify execute_simple was called with the subscriber block + mock_client.execute_simple.assert_called_once() + _, kwargs = mock_client.execute_simple.call_args + execute_data = kwargs["execute"] + assert "subscriber" in execute_data + subscriber = execute_data["subscriber"] + assert subscriber["successUri"].endswith("/unit_jobs/{job_id}/success") + assert subscriber["inProgressUri"].endswith("/unit_jobs/{job_id}/inProgress") + assert subscriber["failedUri"].endswith("/unit_jobs/{job_id}/failed") From 51cf7e9bf35b4d29f18632808d954f4b197ded30 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Fri, 2 Oct 2026 11:06:37 +0200 Subject: [PATCH 02/14] feat(ogc): add subscriber callbacks payload to OGC API execution Enhance the POST /processes/{processID}/execution request payload in OGCAPIProcessPlatform.execute_job() with the 'subscriber' object (successUri, inProgressUri, failedUri) to register APEx callback URIs with the runner according to the OGC API - Processes specification. - Inject subscriber dictionary into execute_job payload in app/platforms/implementations/ogc_api_process.py. - Construct callback URIs dynamically using DISPATCH_API_PUBLIC_URL. - Keep openEO platform execution completely untouched. - Update test assertions in tests/platforms/test_ogc_api_process_platform.py to verify subscriber block presence. --- app/platforms/implementations/ogc_api_process.py | 9 +++++++++ tests/platforms/test_ogc_api_process_platform.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/platforms/implementations/ogc_api_process.py b/app/platforms/implementations/ogc_api_process.py index f9cca3d..c958c64 100644 --- a/app/platforms/implementations/ogc_api_process.py +++ b/app/platforms/implementations/ogc_api_process.py @@ -4,6 +4,7 @@ import requests from app.auth import exchange_token, get_current_user_claims +from app.config.settings import settings from fastapi import Response from loguru import logger @@ -210,9 +211,17 @@ async def execute_job( if user_claims.get("email"): properties["email"] = user_claims["email"] + base_url = settings.dispatch_api_public_url.rstrip("/") + subscriber = { + "successUri": f"{base_url}/unit_jobs/{{job_id}}/success", + "inProgressUri": f"{base_url}/unit_jobs/{{job_id}}/inProgress", + "failedUri": f"{base_url}/unit_jobs/{{job_id}}/failed", + } + data = { "inputs": parameters, "properties": properties, + "subscriber": subscriber, } content = api_client.execute_simple( diff --git a/tests/platforms/test_ogc_api_process_platform.py b/tests/platforms/test_ogc_api_process_platform.py index 9d9cbea..c8d932a 100644 --- a/tests/platforms/test_ogc_api_process_platform.py +++ b/tests/platforms/test_ogc_api_process_platform.py @@ -478,6 +478,11 @@ async def test_execute_job_returns_namespaced_job_id( "username": "alice", "email": "alice@example.com", }, + "subscriber": { + "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", + "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + }, }, _headers={ "accept": "*/*", @@ -530,6 +535,11 @@ async def test_execute_job_returns_plain_job_id_without_namespace( "application": "buffer", "user_id": "user-123", }, + "subscriber": { + "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", + "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + }, }, _headers={ "accept": "*/*", @@ -576,6 +586,11 @@ async def test_execute_job_omits_missing_optional_user_fields( execute={ "inputs": {"limit": 10}, "properties": {"title": "My job", "application": "buffer"}, + "subscriber": { + "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", + "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + }, }, _headers={ "accept": "*/*", From a3cc82626eba6b37024559cede18c060c5eb96e3 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 5 Oct 2026 11:06:57 +0200 Subject: [PATCH 03/14] refactor(errors): adopt upstream problem exception handler and add error details - Update eoap-problems-registry to 1.7.0 and stage-events-client to 1.1.0 in requirements.txt. - Remove custom problem_registry_exception_handler in app/middleware/error_handling.py and import it from eoap_problems_registry.fastapi. - Enrich NotFound and ServerError ProblemRegistryException instances with fine-grained ErrorDetail objects in unit jobs callback endpoints. - Update unit tests in test_unit_jobs_callbacks.py to assert the returned errors detail structure. --- app/middleware/error_handling.py | 27 +++------------- app/routers/unit_jobs.py | 38 ++++++++++++++++++++++- requirements.txt | 4 +-- tests/routers/test_unit_jobs_callbacks.py | 3 ++ 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/app/middleware/error_handling.py b/app/middleware/error_handling.py index f46dc9b..78e29d6 100644 --- a/app/middleware/error_handling.py +++ b/app/middleware/error_handling.py @@ -1,8 +1,10 @@ -import json import traceback from typing import Any -from eoap_problems_registry.fastapi import ProblemRegistryException +from eoap_problems_registry.fastapi import ( + ProblemRegistryException, + problem_registry_exception_handler, +) from fastapi import Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -80,27 +82,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) -async def problem_registry_exception_handler( - request: Request, exc: ProblemRegistryException -): - request_id = correlation_id_ctx.get() - logger.error( - f"ProblemRegistryException raised (request_id={request_id}): " - f"status={exc.status_code}, detail={exc.detail}" - ) - try: - content = json.loads(exc.detail) - except Exception: - content = {"detail": exc.detail} - - return JSONResponse( - status_code=exc.status_code, - content=content, - media_type="application/problem+json", - headers=exc.headers, - ) - - def register_exception_handlers(app): """ Call this in main.py after creating the FastAPI() instance. diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index cfae265..6bdd151 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -12,7 +12,7 @@ JobNotFoundException, ) from app.middleware.error_handling import get_dispatcher_error_response -from eoap_problems_registry import NotFound, ServerError +from eoap_problems_registry import ErrorDetail, NotFound, ServerError from eoap_problems_registry.fastapi import ProblemRegistryException from stage_events_client.models import ( CompletedCloudEvent, @@ -320,6 +320,12 @@ async def on_success_job( raise ProblemRegistryException( problem=NotFound( instance=f"/unit_jobs/{job_id}/success", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"Job {job_id} does not exist in the Job registry", + ) + ], ) ) except ProblemRegistryException: @@ -329,6 +335,12 @@ async def on_success_job( raise ProblemRegistryException( problem=ServerError( instance=f"/unit_jobs/{job_id}/success", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"An error occurred when updating status of Job {job_id}: {e}", + ) + ], ) ) @@ -371,6 +383,12 @@ async def on_progress_job( raise ProblemRegistryException( problem=NotFound( instance=f"/unit_jobs/{job_id}/inProgress", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"Job {job_id} does not exist in the Job registry", + ) + ], ) ) except ProblemRegistryException: @@ -380,6 +398,12 @@ async def on_progress_job( raise ProblemRegistryException( problem=ServerError( instance=f"/unit_jobs/{job_id}/inProgress", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"An error occurred when updating status of Job {job_id}: {e}", + ) + ], ) ) @@ -412,6 +436,12 @@ async def on_failed_job( raise ProblemRegistryException( problem=NotFound( instance=f"/unit_jobs/{job_id}/failed", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"Job {job_id} does not exist in the Job registry", + ) + ], ) ) except ProblemRegistryException: @@ -421,5 +451,11 @@ async def on_failed_job( raise ProblemRegistryException( problem=ServerError( instance=f"/unit_jobs/{job_id}/failed", + errors=[ + ErrorDetail( + parameter="job_id", + detail=f"An error occurred when updating status of Job {job_id}: {e}", + ) + ], ) ) diff --git a/requirements.txt b/requirements.txt index 5e75d6e..e560bcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,5 +32,5 @@ types-requests types-shapely uvicorn[standard] ogc-api-processes-client==0.6.0 -eoap-problems-registry[fastapi]>=1.5.0 -stage-events-client>=1.0.5 +eoap-problems-registry==1.7.0 +stage-events-client==1.1.0 diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index 32b675a..155834f 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -113,6 +113,9 @@ def test_callback_success_404_problem_details(mock_update, client): assert data["status"] == 404 assert data["title"] == "Not Found" assert data["instance"] == "/unit_jobs/nonexistent/success" + assert data["errors"] == [ + {"detail": "Job nonexistent does not exist in the Job registry", "parameter": "job_id"} + ] @patch("app.routers.unit_jobs.update_job_status_from_callback") From 00ab730d6acd6378ab770dc8a35a0a3a3cab4b55 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 5 Oct 2026 12:38:23 +0200 Subject: [PATCH 04/14] refactor(callbacks): make handlers synchronous, return 200 OK, and deduplicate logic - Refactor on_success_job, on_progress_job, and on_failed_job in app/routers/unit_jobs.py from async def to synchronous def to allow FastAPI to offload synchronous database operations to the threadpool. - Change HTTP response status from 202 Accepted to 200 OK across app/routers/unit_jobs.py and openapi.json to accurately reflect immediate, synchronous database state updates. - Introduce _handle_callback_status_update helper function to eliminate repetitive try/except blocks, logging, ErrorDetail, and ProblemRegistryException boilerplate across all callback endpoints. - Update unit tests in tests/routers/test_unit_jobs_callbacks.py to assert HTTP_200_OK. --- app/routers/unit_jobs.py | 140 ++++++++-------------- openapi.json | 16 +-- tests/routers/test_unit_jobs_callbacks.py | 24 ++-- 3 files changed, 69 insertions(+), 111 deletions(-) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index 6bdd151..eb0102c 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -292,34 +292,20 @@ async def delete_job( ) -@router.post( - "/unit_jobs/{job_id}/success", - response_model=None, - status_code=status.HTTP_202_ACCEPTED, - tags=["Unit Jobs"], - summary="URIs for success callbacks for jobs.", - description="The callback URL will be invoked after a job succeeded its execution.\n", - operation_id="onSuccessJob", - responses={ - status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdSuccessPostResponse}, - status.HTTP_404_NOT_FOUND: {"model": NotFound}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, - }, -) -async def on_success_job( +def _handle_callback_status_update( + db: Session, job_id: str, - body: PipedCloudEvent, - db: Session = Depends(get_db), + target_status: ProcessingStatusEnum, + endpoint_name: str, ) -> None: - """URIs for success callbacks for jobs.""" + """Internal helper to update job status and handle RFC 7807 problem exceptions.""" + instance_uri = f"/unit_jobs/{job_id}/{endpoint_name}" try: - job = update_job_status_from_callback( - db, job_id, ProcessingStatusEnum.FINISHED - ) + job = update_job_status_from_callback(db, job_id, target_status) if not job: raise ProblemRegistryException( problem=NotFound( - instance=f"/unit_jobs/{job_id}/success", + instance=instance_uri, errors=[ ErrorDetail( parameter="job_id", @@ -331,10 +317,10 @@ async def on_success_job( except ProblemRegistryException: raise except Exception as e: - logger.error(f"Error handling success callback for job {job_id}: {e}") + logger.error(f"Error handling {endpoint_name} callback for job {job_id}: {e}") raise ProblemRegistryException( problem=ServerError( - instance=f"/unit_jobs/{job_id}/success", + instance=instance_uri, errors=[ ErrorDetail( parameter="job_id", @@ -345,10 +331,36 @@ async def on_success_job( ) +@router.post( + "/unit_jobs/{job_id}/success", + response_model=None, + status_code=status.HTTP_200_OK, + tags=["Unit Jobs"], + summary="URIs for success callbacks for jobs.", + description="The callback URL will be invoked after a job succeeded its execution.\n", + operation_id="onSuccessJob", + responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdSuccessPostResponse}, + status.HTTP_404_NOT_FOUND: {"model": NotFound}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + }, +) +def on_success_job( + job_id: str, + body: PipedCloudEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for success callbacks for jobs.""" + _handle_callback_status_update( + db, job_id, ProcessingStatusEnum.FINISHED, "success" + ) + + @router.post( "/unit_jobs/{job_id}/inProgress", response_model=None, - status_code=status.HTTP_202_ACCEPTED, + status_code=status.HTTP_200_OK, tags=["Unit Jobs"], summary="URIs for progress callbacks for jobs.", description=( @@ -357,12 +369,13 @@ async def on_success_job( ), operation_id="onProgressJob", responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdInProgressPostResponse}, status.HTTP_404_NOT_FOUND: {"model": NotFound}, status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, }, ) -async def on_progress_job( +def on_progress_job( job_id: str, body: ( SubmittedCloudEvent @@ -374,88 +387,33 @@ async def on_progress_job( db: Session = Depends(get_db), ) -> None: """URIs for progress callbacks for jobs.""" - try: - target_status = STAGE_TO_APEX_STATUS.get( - body.type, ProcessingStatusEnum.RUNNING - ) - job = update_job_status_from_callback(db, job_id, target_status) - if not job: - raise ProblemRegistryException( - problem=NotFound( - instance=f"/unit_jobs/{job_id}/inProgress", - errors=[ - ErrorDetail( - parameter="job_id", - detail=f"Job {job_id} does not exist in the Job registry", - ) - ], - ) - ) - except ProblemRegistryException: - raise - except Exception as e: - logger.error(f"Error handling inProgress callback for job {job_id}: {e}") - raise ProblemRegistryException( - problem=ServerError( - instance=f"/unit_jobs/{job_id}/inProgress", - errors=[ - ErrorDetail( - parameter="job_id", - detail=f"An error occurred when updating status of Job {job_id}: {e}", - ) - ], - ) - ) + target_status = STAGE_TO_APEX_STATUS.get( + body.type, ProcessingStatusEnum.RUNNING + ) + _handle_callback_status_update(db, job_id, target_status, "inProgress") @router.post( "/unit_jobs/{job_id}/failed", response_model=None, - status_code=status.HTTP_202_ACCEPTED, + status_code=status.HTTP_200_OK, tags=["Unit Jobs"], summary="URIs for failed callbacks for jobs.", description="The callback URL will be invoked after a job failed its execution.\n", operation_id="onFailedJob", responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdFailedPostResponse}, status.HTTP_404_NOT_FOUND: {"model": NotFound}, status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, }, ) -async def on_failed_job( +def on_failed_job( job_id: str, body: FailedCloudEvent, db: Session = Depends(get_db), ) -> None: """URIs for failed callbacks for jobs.""" - try: - job = update_job_status_from_callback( - db, job_id, ProcessingStatusEnum.FAILED - ) - if not job: - raise ProblemRegistryException( - problem=NotFound( - instance=f"/unit_jobs/{job_id}/failed", - errors=[ - ErrorDetail( - parameter="job_id", - detail=f"Job {job_id} does not exist in the Job registry", - ) - ], - ) - ) - except ProblemRegistryException: - raise - except Exception as e: - logger.error(f"Error handling failed callback for job {job_id}: {e}") - raise ProblemRegistryException( - problem=ServerError( - instance=f"/unit_jobs/{job_id}/failed", - errors=[ - ErrorDetail( - parameter="job_id", - detail=f"An error occurred when updating status of Job {job_id}: {e}", - ) - ], - ) - ) + _handle_callback_status_update( + db, job_id, ProcessingStatusEnum.FAILED, "failed" + ) diff --git a/openapi.json b/openapi.json index 885966e..ea5b4da 100644 --- a/openapi.json +++ b/openapi.json @@ -726,8 +726,8 @@ "$ref": "#/components/requestBodies/SuccessRequestBody" }, "responses": { - "202": { - "$ref": "#/components/responses/Accepted" + "200": { + "$ref": "#/components/responses/Ok" }, "400": { "$ref": "#/components/responses/BadRequest" @@ -755,8 +755,8 @@ "$ref": "#/components/requestBodies/InProgressRequestBody" }, "responses": { - "202": { - "$ref": "#/components/responses/Accepted" + "200": { + "$ref": "#/components/responses/Ok" }, "400": { "$ref": "#/components/responses/BadRequest" @@ -784,8 +784,8 @@ "$ref": "#/components/requestBodies/FailedRequestBody" }, "responses": { - "202": { - "$ref": "#/components/responses/Accepted" + "200": { + "$ref": "#/components/responses/Ok" }, "400": { "$ref": "#/components/responses/BadRequest" @@ -2696,8 +2696,8 @@ } }, "responses": { - "Accepted": { - "description": "Request has been accepted for processing, but processing will be asychronously executed.\n" + "Ok": { + "description": "Request has been processed and job status has been successfully updated.\n" }, "BadRequest": { "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/BadRequest" diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index 155834f..bf3cb6f 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -86,13 +86,13 @@ # --- Inbound Callback Tests --- @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_success_202(mock_update, client): +def test_callback_success_200(mock_update, client): r = client.post( "/unit_jobs/job-123/success", json=PIPED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK mock_update.assert_called_once() args, _ = mock_update.call_args assert args[1] == "job-123" @@ -119,65 +119,65 @@ def test_callback_success_404_problem_details(mock_update, client): @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_staged_202(mock_update, client): +def test_callback_in_progress_staged_200(mock_update, client): r = client.post( "/unit_jobs/job-123/inProgress", json=STAGED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args assert args[1] == "job-123" assert args[2] == ProcessingStatusEnum.RUNNING @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_prepared_202(mock_update, client): +def test_callback_in_progress_prepared_200(mock_update, client): r = client.post( "/unit_jobs/job-123/inProgress", json=PREPARED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args assert args[1] == "job-123" assert args[2] == ProcessingStatusEnum.QUEUED @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_submitted_202(mock_update, client): +def test_callback_in_progress_submitted_200(mock_update, client): r = client.post( "/unit_jobs/job-123/inProgress", json=SUBMITTED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args assert args[1] == "job-123" assert args[2] == ProcessingStatusEnum.CREATED @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_dismissed_202(mock_update, client): +def test_callback_in_progress_dismissed_200(mock_update, client): r = client.post( "/unit_jobs/job-123/inProgress", json=DISMISSED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args assert args[1] == "job-123" assert args[2] == ProcessingStatusEnum.CANCELED @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_failed_202(mock_update, client): +def test_callback_failed_200(mock_update, client): r = client.post( "/unit_jobs/job-123/failed", json=FAILED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_202_ACCEPTED + assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args assert args[1] == "job-123" assert args[2] == ProcessingStatusEnum.FAILED From ead78fe0616b6b88771a6a4f36d60b374a45e21c Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 12 Oct 2026 17:01:15 +0200 Subject: [PATCH 05/14] refactor(status): update stage-to-status mappings - Update event status mapping: submitted -> QUEUED, prepared -> RUNNING, piped/completed -> FINISHED. - Update corresponding unit test assertions. --- app/platforms/implementations/ogc_api_process.py | 2 +- app/schemas/callbacks.py | 5 +++-- app/services/processing.py | 2 +- tests/platforms/test_ogc_api_process_platform.py | 2 +- tests/services/test_processing.py | 6 +++--- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/platforms/implementations/ogc_api_process.py b/app/platforms/implementations/ogc_api_process.py index c958c64..69c2de1 100644 --- a/app/platforms/implementations/ogc_api_process.py +++ b/app/platforms/implementations/ogc_api_process.py @@ -51,7 +51,7 @@ class OGCAPIProcessPlatform(BaseProcessingPlatform): } status_mapping = { - StatusCode.ACCEPTED: ProcessingStatusEnum.CREATED, + StatusCode.ACCEPTED: ProcessingStatusEnum.QUEUED, StatusCode.RUNNING: ProcessingStatusEnum.RUNNING, StatusCode.DISMISSED: ProcessingStatusEnum.CANCELED, StatusCode.SUCCESSFUL: ProcessingStatusEnum.FINISHED, diff --git a/app/schemas/callbacks.py b/app/schemas/callbacks.py index e0de6e3..d6ccc3a 100644 --- a/app/schemas/callbacks.py +++ b/app/schemas/callbacks.py @@ -19,13 +19,14 @@ PreparedCloudEvent, CompletedCloudEvent, StagedCloudEvent, + PipedCloudEvent, ) from app.schemas.enum import ProcessingStatusEnum STAGE_TO_APEX_STATUS: dict[str, ProcessingStatusEnum] = { - "prepared": ProcessingStatusEnum.QUEUED, - "submitted": ProcessingStatusEnum.CREATED, + "submitted": ProcessingStatusEnum.QUEUED, + "prepared": ProcessingStatusEnum.RUNNING, "staged": ProcessingStatusEnum.RUNNING, "piped": ProcessingStatusEnum.FINISHED, "completed": ProcessingStatusEnum.FINISHED, diff --git a/app/services/processing.py b/app/services/processing.py index 75f3fe4..640395e 100644 --- a/app/services/processing.py +++ b/app/services/processing.py @@ -59,7 +59,7 @@ async def create_processing_job( title=request.title, label=request.label, status=( - ProcessingStatusEnum.CREATED if job_id else ProcessingStatusEnum.FAILED + ProcessingStatusEnum.QUEUED if job_id else ProcessingStatusEnum.FAILED ), user_id=user, platform_job_id=job_id, diff --git a/tests/platforms/test_ogc_api_process_platform.py b/tests/platforms/test_ogc_api_process_platform.py index c8d932a..f3307a2 100644 --- a/tests/platforms/test_ogc_api_process_platform.py +++ b/tests/platforms/test_ogc_api_process_platform.py @@ -353,7 +353,7 @@ def test_split_job_id(platform, job_id, expected): @pytest.mark.parametrize( ("ogc_status, expected_status"), [ - ("accepted", ProcessingStatusEnum.CREATED), + ("accepted", ProcessingStatusEnum.QUEUED), ("running", ProcessingStatusEnum.RUNNING), ("dismissed", ProcessingStatusEnum.CANCELED), ("successful", ProcessingStatusEnum.FINISHED), diff --git a/tests/services/test_processing.py b/tests/services/test_processing.py index 45f1ad7..641cbc6 100644 --- a/tests/services/test_processing.py +++ b/tests/services/test_processing.py @@ -66,7 +66,7 @@ async def test_create_processing_job_calls_platform_execute( id=fake_result, title=fake_job.title, label=ProcessTypeEnum.OPENEO, - status=ProcessingStatusEnum.CREATED, + status=ProcessingStatusEnum.QUEUED, parameters=fake_job.parameters, service=fake_job.service, ) @@ -74,7 +74,7 @@ async def test_create_processing_job_calls_platform_execute( id=fake_result, title=fake_summary.title, label=ProcessTypeEnum.OPENEO, - status=ProcessingStatusEnum.CREATED, + status=ProcessingStatusEnum.QUEUED, ) fake_platform = MagicMock() @@ -100,7 +100,7 @@ async def test_create_processing_job_calls_platform_execute( mock_save_job_to_db.assert_called_once() args, _ = mock_save_job_to_db.call_args saved_record = args[1] - assert saved_record.status == ProcessingStatusEnum.CREATED + assert saved_record.status == ProcessingStatusEnum.QUEUED assert result == fake_summary From 29a8407840cd0484a8f56a5730451b911184f44b Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 12 Oct 2026 17:02:56 +0200 Subject: [PATCH 06/14] refactor(services): support 3-part subjects in job lookup - Allow looking up jobs using :: format. - Clean up noisy debug logs in lookup functions. --- app/services/processing.py | 20 ++++++++----- tests/services/test_processing.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/app/services/processing.py b/app/services/processing.py index 640395e..a0ecbba 100644 --- a/app/services/processing.py +++ b/app/services/processing.py @@ -149,17 +149,21 @@ async def _refresh_job_status( def get_job_by_platform_or_id( database: Session, job_identifier: str ) -> Optional[ProcessingJobRecord]: - logger.info(f"Retrieving processing job with identifier {job_identifier}") if job_identifier.isdigit(): return get_job_by_id(database, int(job_identifier)) - suffix = job_identifier.split(":")[-1] + candidates = [job_identifier] + parts = job_identifier.split(":") + if len(parts) == 3: + # e.g. "::" -> ":" + candidates.append(f"{parts[0]}:{parts[2]}") + candidates.append(parts[-1]) + return ( database.query(ProcessingJobRecord) .filter( - (ProcessingJobRecord.platform_job_id == job_identifier) - | (ProcessingJobRecord.platform_job_id == suffix) - | (ProcessingJobRecord.platform_job_id.endswith(f":{suffix}")) + ProcessingJobRecord.platform_job_id.in_(candidates) + | ProcessingJobRecord.platform_job_id.endswith(f":{parts[-1]}") ) .first() ) @@ -173,13 +177,13 @@ def update_job_status_from_callback( ) job = get_job_by_platform_or_id(database, job_identifier) if not job: - logger.warning( - f"Job '{job_identifier}' not found for status update callback" - ) return None update_job_status_by_id(database, job.id, status) job.status = status + logger.info( + f"Successfully updated status of job '{job_identifier}' (id={job.id}) to {status}" + ) return job diff --git a/tests/services/test_processing.py b/tests/services/test_processing.py index 641cbc6..0414661 100644 --- a/tests/services/test_processing.py +++ b/tests/services/test_processing.py @@ -657,3 +657,52 @@ async def test_delete_processing_job_noop_when_not_found( mock_get_job.assert_called_once_with(fake_db_session, 1, "foobar") mock_remove_job.assert_not_called() + + +def test_get_job_by_platform_or_id_numeric(fake_db_session, fake_processing_job_record): + from app.services.processing import get_job_by_platform_or_id + + with patch("app.services.processing.get_job_by_id", return_value=fake_processing_job_record) as mock_get_by_id: + result = get_job_by_platform_or_id(fake_db_session, "123") + assert result == fake_processing_job_record + mock_get_by_id.assert_called_once_with(fake_db_session, 123) + + +def test_get_job_by_platform_or_id_string(fake_db_session, fake_processing_job_record): + from app.services.processing import get_job_by_platform_or_id + + fake_query = MagicMock() + fake_db_session.query.return_value = fake_query + fake_query.filter.return_value = fake_query + fake_query.first.return_value = fake_processing_job_record + + # 3-part CloudEvent subject format + result = get_job_by_platform_or_id(fake_db_session, "myns:uid-1:wf-abc") + assert result == fake_processing_job_record + fake_db_session.query.assert_called_once() + + +def test_update_job_status_from_callback_success(fake_db_session, fake_processing_job_record): + from app.services.processing import update_job_status_from_callback + + with patch("app.services.processing.get_job_by_platform_or_id", return_value=fake_processing_job_record), \ + patch("app.services.processing.update_job_status_by_id") as mock_update_db: + result = update_job_status_from_callback( + fake_db_session, "myns:wf-abc", ProcessingStatusEnum.FINISHED + ) + assert result == fake_processing_job_record + assert result.status == ProcessingStatusEnum.FINISHED + mock_update_db.assert_called_once_with( + fake_db_session, fake_processing_job_record.id, ProcessingStatusEnum.FINISHED + ) + + +def test_update_job_status_from_callback_not_found(fake_db_session): + from app.services.processing import update_job_status_from_callback + + with patch("app.services.processing.get_job_by_platform_or_id", return_value=None): + result = update_job_status_from_callback( + fake_db_session, "nonexistent", ProcessingStatusEnum.RUNNING + ) + assert result is None + From 4fe60a2174500603acd21b7ea87e0c3ba7ab6c7d Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 12 Oct 2026 17:04:25 +0200 Subject: [PATCH 07/14] refactor(callbacks): remove job_id from callback endpoints - Switch endpoints to /unit_jobs/success, /inProgress, and /failed. - Read job identity directly from CloudEvent body. - Update subscriber URLs and openapi.json. --- .../implementations/ogc_api_process.py | 6 +- app/routers/unit_jobs.py | 55 ++++--- openapi.json | 35 ++--- .../test_ogc_api_process_platform.py | 18 +-- tests/routers/test_unit_jobs_callbacks.py | 140 ++++++++++++++---- 5 files changed, 173 insertions(+), 81 deletions(-) diff --git a/app/platforms/implementations/ogc_api_process.py b/app/platforms/implementations/ogc_api_process.py index 69c2de1..d54b6da 100644 --- a/app/platforms/implementations/ogc_api_process.py +++ b/app/platforms/implementations/ogc_api_process.py @@ -213,9 +213,9 @@ async def execute_job( base_url = settings.dispatch_api_public_url.rstrip("/") subscriber = { - "successUri": f"{base_url}/unit_jobs/{{job_id}}/success", - "inProgressUri": f"{base_url}/unit_jobs/{{job_id}}/inProgress", - "failedUri": f"{base_url}/unit_jobs/{{job_id}}/failed", + "successUri": f"{base_url}/unit_jobs/success", + "inProgressUri": f"{base_url}/unit_jobs/inProgress", + "failedUri": f"{base_url}/unit_jobs/failed", } data = { diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index eb0102c..f53c32a 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -15,6 +15,7 @@ from eoap_problems_registry import ErrorDetail, NotFound, ServerError from eoap_problems_registry.fastapi import ProblemRegistryException from stage_events_client.models import ( + BaseCloudEvent, CompletedCloudEvent, DismissedCloudEvent, FailedCloudEvent, @@ -292,24 +293,46 @@ async def delete_job( ) +def _extract_job_identifier_from_cloudevent(cloud_event: BaseCloudEvent) -> str | None: + """Extract job_identifier strictly from CloudEvent subject metadata.""" + # CloudEvent subject pattern: "::" + subject = getattr(cloud_event, "subject", None) + if not subject: + return None + + parts = subject.split(":") + if len(parts) == 3: + return f"{parts[0]}:{parts[2]}" + return subject + + def _handle_callback_status_update( db: Session, - job_id: str, + cloud_event: BaseCloudEvent, target_status: ProcessingStatusEnum, endpoint_name: str, ) -> None: - """Internal helper to update job status and handle RFC 7807 problem exceptions.""" - instance_uri = f"/unit_jobs/{job_id}/{endpoint_name}" + """Internal helper to update job status from callback CloudEvents.""" + job_identifier = _extract_job_identifier_from_cloudevent(cloud_event) or "" + instance_uri = f"/unit_jobs/{endpoint_name}" + + logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") + try: - job = update_job_status_from_callback(db, job_id, target_status) + job = update_job_status_from_callback(db, job_identifier, target_status) if not job: + if endpoint_name == "inProgress": + # An early inProgress callback may arrive before initial job commit finishes; acknowledge gracefully without returning 404. + logger.debug(f"Job '{job_identifier}' is not registered in database yet; acknowledging in progress") + return + raise ProblemRegistryException( problem=NotFound( instance=instance_uri, errors=[ ErrorDetail( parameter="job_id", - detail=f"Job {job_id} does not exist in the Job registry", + detail=f"Job {job_identifier} does not exist in the Job registry", ) ], ) @@ -317,14 +340,14 @@ def _handle_callback_status_update( except ProblemRegistryException: raise except Exception as e: - logger.error(f"Error handling {endpoint_name} callback for job {job_id}: {e}") + logger.error(f"Error handling {endpoint_name} callback for job {job_identifier}: {e}") raise ProblemRegistryException( problem=ServerError( instance=instance_uri, errors=[ ErrorDetail( parameter="job_id", - detail=f"An error occurred when updating status of Job {job_id}: {e}", + detail=f"An error occurred when updating status of Job {job_identifier}: {e}", ) ], ) @@ -332,7 +355,7 @@ def _handle_callback_status_update( @router.post( - "/unit_jobs/{job_id}/success", + "/unit_jobs/success", response_model=None, status_code=status.HTTP_200_OK, tags=["Unit Jobs"], @@ -347,18 +370,17 @@ def _handle_callback_status_update( }, ) def on_success_job( - job_id: str, - body: PipedCloudEvent, + body: PipedCloudEvent | CompletedCloudEvent, db: Session = Depends(get_db), ) -> None: """URIs for success callbacks for jobs.""" _handle_callback_status_update( - db, job_id, ProcessingStatusEnum.FINISHED, "success" + db, body, ProcessingStatusEnum.FINISHED, "success" ) @router.post( - "/unit_jobs/{job_id}/inProgress", + "/unit_jobs/inProgress", response_model=None, status_code=status.HTTP_200_OK, tags=["Unit Jobs"], @@ -376,12 +398,10 @@ def on_success_job( }, ) def on_progress_job( - job_id: str, body: ( SubmittedCloudEvent | DismissedCloudEvent | PreparedCloudEvent - | CompletedCloudEvent | StagedCloudEvent ), db: Session = Depends(get_db), @@ -390,11 +410,11 @@ def on_progress_job( target_status = STAGE_TO_APEX_STATUS.get( body.type, ProcessingStatusEnum.RUNNING ) - _handle_callback_status_update(db, job_id, target_status, "inProgress") + _handle_callback_status_update(db, body, target_status, "inProgress") @router.post( - "/unit_jobs/{job_id}/failed", + "/unit_jobs/failed", response_model=None, status_code=status.HTTP_200_OK, tags=["Unit Jobs"], @@ -409,11 +429,10 @@ def on_progress_job( }, ) def on_failed_job( - job_id: str, body: FailedCloudEvent, db: Session = Depends(get_db), ) -> None: """URIs for failed callbacks for jobs.""" _handle_callback_status_update( - db, job_id, ProcessingStatusEnum.FAILED, "failed" + db, body, ProcessingStatusEnum.FAILED, "failed" ) diff --git a/openapi.json b/openapi.json index ea5b4da..fb67edf 100644 --- a/openapi.json +++ b/openapi.json @@ -712,16 +712,11 @@ } } }, - "/unit_jobs/{job_id}/success": { + "/unit_jobs/success": { "post": { "operationId": "onSuccessJob", "summary": "URIs for success callbacks for jobs.", "description": "The callback URL will be invoked after a job succeeded its execution.\n", - "parameters": [ - { - "$ref": "#/components/parameters/job_id" - } - ], "requestBody": { "$ref": "#/components/requestBodies/SuccessRequestBody" }, @@ -741,16 +736,11 @@ } } }, - "/unit_jobs/{job_id}/inProgress": { + "/unit_jobs/inProgress": { "post": { "operationId": "onProgressJob", "summary": "URIs for progress callbacks for jobs.", "description": "The callback URL will be invoked while a job is changing its status during its execution.\n", - "parameters": [ - { - "$ref": "#/components/parameters/job_id" - } - ], "requestBody": { "$ref": "#/components/requestBodies/InProgressRequestBody" }, @@ -770,16 +760,11 @@ } } }, - "/unit_jobs/{job_id}/failed": { + "/unit_jobs/failed": { "post": { "operationId": "onFailedJob", "summary": "URIs for failed callbacks for jobs.", "description": "The callback URL will be invoked after a job failed its execution.\n", - "parameters": [ - { - "$ref": "#/components/parameters/job_id" - } - ], "requestBody": { "$ref": "#/components/requestBodies/FailedRequestBody" }, @@ -2651,13 +2636,20 @@ "content": { "application/cloudevents+json": { "schema": { - "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/PipedCloudEvent" + "oneOf": [ + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/PipedCloudEvent" + }, + { + "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/CompletedCloudEvent" + } + ] } } } }, "InProgressRequestBody": { - "description": "A structured-mode CloudEvent request body for failures.", + "description": "A structured-mode CloudEvent request body for in-progress updates.", "required": true, "content": { "application/cloudevents+json": { @@ -2672,9 +2664,6 @@ { "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/PreparedCloudEvent" }, - { - "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/CompletedCloudEvent" - }, { "$ref": "https://terradue.github.io/stage-events-client/c4/class/schemas/openapi.yaml#/components/schemas/StagedCloudEvent" } diff --git a/tests/platforms/test_ogc_api_process_platform.py b/tests/platforms/test_ogc_api_process_platform.py index f3307a2..adfa0b4 100644 --- a/tests/platforms/test_ogc_api_process_platform.py +++ b/tests/platforms/test_ogc_api_process_platform.py @@ -479,9 +479,9 @@ async def test_execute_job_returns_namespaced_job_id( "email": "alice@example.com", }, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", - "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + "successUri": "http://localhost:8000/unit_jobs/success", + "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/failed", }, }, _headers={ @@ -536,9 +536,9 @@ async def test_execute_job_returns_plain_job_id_without_namespace( "user_id": "user-123", }, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", - "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + "successUri": "http://localhost:8000/unit_jobs/success", + "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/failed", }, }, _headers={ @@ -587,9 +587,9 @@ async def test_execute_job_omits_missing_optional_user_fields( "inputs": {"limit": 10}, "properties": {"title": "My job", "application": "buffer"}, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/{job_id}/success", - "inProgressUri": "http://localhost:8000/unit_jobs/{job_id}/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/{job_id}/failed", + "successUri": "http://localhost:8000/unit_jobs/success", + "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", + "failedUri": "http://localhost:8000/unit_jobs/failed", }, }, _headers={ diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index bf3cb6f..a3dcb9c 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -10,7 +10,7 @@ # --- Test Payloads --- PIPED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "piped", "data": { @@ -23,7 +23,7 @@ STAGED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "staged", "data": { @@ -36,7 +36,7 @@ PREPARED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "prepared", "data": { @@ -49,7 +49,7 @@ SUBMITTED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "submitted", "data": { @@ -60,7 +60,7 @@ DISMISSED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "dismissed", "data": { @@ -71,7 +71,7 @@ FAILED_PAYLOAD = { "source": "terradue:apex:test", - "subject": "job:step:1", + "subject": "default:test-uid:job-123", "partitionkey": "part-1", "type": "failed", "data": { @@ -82,20 +82,48 @@ }, } +COMPLETED_PAYLOAD = { + "source": "terradue:apex:test", + "subject": "default:test-uid:job-123", + "partitionkey": "part-1", + "type": "completed", + "data": { + "namespace": "default", + "process_id": "test-proc", + "job_id": "job-123", + "outputs": {}, + "usage": {}, + }, +} + # --- Inbound Callback Tests --- @patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_success_200(mock_update, client): +def test_callback_success_piped_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/success", + "/unit_jobs/success", json=PIPED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK mock_update.assert_called_once() args, _ = mock_update.call_args - assert args[1] == "job-123" + assert args[1] == "default:job-123" + assert args[2] == ProcessingStatusEnum.FINISHED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_success_completed_200(mock_update, client): + r = client.post( + "/unit_jobs/success", + json=COMPLETED_PAYLOAD, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_200_OK + mock_update.assert_called_once() + args, _ = mock_update.call_args + assert args[1] == "default:job-123" assert args[2] == ProcessingStatusEnum.FINISHED @@ -103,8 +131,8 @@ def test_callback_success_200(mock_update, client): def test_callback_success_404_problem_details(mock_update, client): mock_update.return_value = None r = client.post( - "/unit_jobs/nonexistent/success", - json=PIPED_PAYLOAD, + "/unit_jobs/success", + json={**PIPED_PAYLOAD, "subject": "default:test-uid:nonexistent"}, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_404_NOT_FOUND @@ -112,77 +140,132 @@ def test_callback_success_404_problem_details(mock_update, client): data = r.json() assert data["status"] == 404 assert data["title"] == "Not Found" - assert data["instance"] == "/unit_jobs/nonexistent/success" + assert data["instance"] == "/unit_jobs/success" assert data["errors"] == [ - {"detail": "Job nonexistent does not exist in the Job registry", "parameter": "job_id"} + {"detail": "Job default:nonexistent does not exist in the Job registry", "parameter": "job_id"} ] @patch("app.routers.unit_jobs.update_job_status_from_callback") def test_callback_in_progress_staged_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/inProgress", + "/unit_jobs/inProgress", json=STAGED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args - assert args[1] == "job-123" + assert args[1] == "default:job-123" assert args[2] == ProcessingStatusEnum.RUNNING @patch("app.routers.unit_jobs.update_job_status_from_callback") def test_callback_in_progress_prepared_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/inProgress", + "/unit_jobs/inProgress", json=PREPARED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args - assert args[1] == "job-123" - assert args[2] == ProcessingStatusEnum.QUEUED + assert args[1] == "default:job-123" + assert args[2] == ProcessingStatusEnum.RUNNING @patch("app.routers.unit_jobs.update_job_status_from_callback") def test_callback_in_progress_submitted_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/inProgress", + "/unit_jobs/inProgress", json=SUBMITTED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args - assert args[1] == "job-123" - assert args[2] == ProcessingStatusEnum.CREATED + assert args[1] == "default:job-123" + assert args[2] == ProcessingStatusEnum.QUEUED @patch("app.routers.unit_jobs.update_job_status_from_callback") def test_callback_in_progress_dismissed_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/inProgress", + "/unit_jobs/inProgress", json=DISMISSED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args - assert args[1] == "job-123" + assert args[1] == "default:job-123" assert args[2] == ProcessingStatusEnum.CANCELED @patch("app.routers.unit_jobs.update_job_status_from_callback") def test_callback_failed_200(mock_update, client): r = client.post( - "/unit_jobs/job-123/failed", + "/unit_jobs/failed", json=FAILED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) assert r.status_code == status.HTTP_200_OK args, _ = mock_update.call_args - assert args[1] == "job-123" + assert args[1] == "default:job-123" assert args[2] == ProcessingStatusEnum.FAILED +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_resolves_3part_subject_from_cloudevent(mock_update, client): + payload = { + **PIPED_PAYLOAD, + "source": "mmiraftab:opensartoolkit:stage-out", + "subject": "mmiraftab:test-uid:opensartoolkit-v2-2-1-p6ms6", + } + r = client.post( + "/unit_jobs/success", + json=payload, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_200_OK + mock_update.assert_called_once() + args, _ = mock_update.call_args + assert args[1] == "mmiraftab:opensartoolkit-v2-2-1-p6ms6" + assert args[2] == ProcessingStatusEnum.FINISHED + + +@patch("app.routers.unit_jobs.update_job_status_from_callback") +def test_callback_in_progress_uncommitted_job_acknowledges_cleanly(mock_update, client): + mock_update.return_value = None # simulate job not committed yet + payload = { + **SUBMITTED_PAYLOAD, + "source": "mmiraftab:opensartoolkit:main", + "subject": "mmiraftab:test-uid:opensartoolkit-v2-2-1-p6ms6", + } + r = client.post( + "/unit_jobs/inProgress", + json=payload, + headers={"Content-Type": "application/cloudevents+json"}, + ) + assert r.status_code == status.HTTP_200_OK + + +def test_extract_job_identifier_from_cloudevent(): + from app.routers.unit_jobs import _extract_job_identifier_from_cloudevent + + # 3-part subject (::) + event = MagicMock(subject="myns:uid-1234:wf-5678") + assert _extract_job_identifier_from_cloudevent(event) == "myns:wf-5678" + + # 2-part subject + event = MagicMock(subject="myns:wf-5678") + assert _extract_job_identifier_from_cloudevent(event) == "myns:wf-5678" + + # Single string + event = MagicMock(subject="wf-5678") + assert _extract_job_identifier_from_cloudevent(event) == "wf-5678" + + # Missing / None subject + event = MagicMock(subject=None) + assert _extract_job_identifier_from_cloudevent(event) is None + + # --- Outbound Subscriber Payload Test --- @pytest.mark.asyncio @@ -231,6 +314,7 @@ async def test_ogc_platform_execute_job_injects_subscriber( execute_data = kwargs["execute"] assert "subscriber" in execute_data subscriber = execute_data["subscriber"] - assert subscriber["successUri"].endswith("/unit_jobs/{job_id}/success") - assert subscriber["inProgressUri"].endswith("/unit_jobs/{job_id}/inProgress") - assert subscriber["failedUri"].endswith("/unit_jobs/{job_id}/failed") + assert subscriber["successUri"].endswith("/unit_jobs/success") + assert subscriber["inProgressUri"].endswith("/unit_jobs/inProgress") + assert subscriber["failedUri"].endswith("/unit_jobs/failed") + From e08e3cbaa68b60485d259ba07086b2874d7d3b1b Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Wed, 14 Oct 2026 11:03:03 +0200 Subject: [PATCH 08/14] refactor(unit-jobs): extract endpoint name from request URL --- app/routers/unit_jobs.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index f53c32a..618ab1c 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -1,5 +1,5 @@ from typing import Annotated -from fastapi import Body, APIRouter, Depends, status +from fastapi import Body, APIRouter, Depends, Request, status from loguru import logger from sqlalchemy.orm import Session @@ -310,11 +310,12 @@ def _handle_callback_status_update( db: Session, cloud_event: BaseCloudEvent, target_status: ProcessingStatusEnum, - endpoint_name: str, + request: Request, ) -> None: """Internal helper to update job status from callback CloudEvents.""" job_identifier = _extract_job_identifier_from_cloudevent(cloud_event) or "" - instance_uri = f"/unit_jobs/{endpoint_name}" + instance_uri = request.url.path + endpoint_name = instance_uri.split("/")[-1] logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") @@ -370,12 +371,13 @@ def _handle_callback_status_update( }, ) def on_success_job( + request: Request, body: PipedCloudEvent | CompletedCloudEvent, db: Session = Depends(get_db), ) -> None: """URIs for success callbacks for jobs.""" _handle_callback_status_update( - db, body, ProcessingStatusEnum.FINISHED, "success" + db, body, ProcessingStatusEnum.FINISHED, request ) @@ -398,6 +400,7 @@ def on_success_job( }, ) def on_progress_job( + request: Request, body: ( SubmittedCloudEvent | DismissedCloudEvent @@ -410,7 +413,7 @@ def on_progress_job( target_status = STAGE_TO_APEX_STATUS.get( body.type, ProcessingStatusEnum.RUNNING ) - _handle_callback_status_update(db, body, target_status, "inProgress") + _handle_callback_status_update(db, body, target_status, request) @router.post( @@ -429,10 +432,11 @@ def on_progress_job( }, ) def on_failed_job( + request: Request, body: FailedCloudEvent, db: Session = Depends(get_db), ) -> None: """URIs for failed callbacks for jobs.""" _handle_callback_status_update( - db, body, ProcessingStatusEnum.FAILED, "failed" + db, body, ProcessingStatusEnum.FAILED, request ) From b1f18055abbf70701a202de6bd916ea4bd85ce78 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Wed, 14 Oct 2026 12:56:16 +0200 Subject: [PATCH 09/14] chore: clean up code formatting and unused imports for flake8 --- app/routers/unit_jobs.py | 12 +++++++++--- app/schemas/callbacks.py | 1 - tests/routers/test_unit_jobs_callbacks.py | 6 ++++-- tests/services/test_processing.py | 14 ++++++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index 618ab1c..c437f31 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -323,8 +323,12 @@ def _handle_callback_status_update( job = update_job_status_from_callback(db, job_identifier, target_status) if not job: if endpoint_name == "inProgress": - # An early inProgress callback may arrive before initial job commit finishes; acknowledge gracefully without returning 404. - logger.debug(f"Job '{job_identifier}' is not registered in database yet; acknowledging in progress") + # An early inProgress callback may arrive before initial job commit finishes; + # acknowledge gracefully without returning 404. + logger.debug( + f"Job '{job_identifier}' is not registered in database yet; " + "acknowledging in progress" + ) return raise ProblemRegistryException( @@ -348,7 +352,9 @@ def _handle_callback_status_update( errors=[ ErrorDetail( parameter="job_id", - detail=f"An error occurred when updating status of Job {job_identifier}: {e}", + detail=( + f"An error occurred when updating status of Job {job_identifier}: {e}" + ), ) ], ) diff --git a/app/schemas/callbacks.py b/app/schemas/callbacks.py index d6ccc3a..e8ba98a 100644 --- a/app/schemas/callbacks.py +++ b/app/schemas/callbacks.py @@ -19,7 +19,6 @@ PreparedCloudEvent, CompletedCloudEvent, StagedCloudEvent, - PipedCloudEvent, ) from app.schemas.enum import ProcessingStatusEnum diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index a3dcb9c..17c1c17 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -142,7 +142,10 @@ def test_callback_success_404_problem_details(mock_update, client): assert data["title"] == "Not Found" assert data["instance"] == "/unit_jobs/success" assert data["errors"] == [ - {"detail": "Job default:nonexistent does not exist in the Job registry", "parameter": "job_id"} + { + "detail": "Job default:nonexistent does not exist in the Job registry", + "parameter": "job_id", + } ] @@ -317,4 +320,3 @@ async def test_ogc_platform_execute_job_injects_subscriber( assert subscriber["successUri"].endswith("/unit_jobs/success") assert subscriber["inProgressUri"].endswith("/unit_jobs/inProgress") assert subscriber["failedUri"].endswith("/unit_jobs/failed") - diff --git a/tests/services/test_processing.py b/tests/services/test_processing.py index 0414661..0750ba1 100644 --- a/tests/services/test_processing.py +++ b/tests/services/test_processing.py @@ -662,7 +662,9 @@ async def test_delete_processing_job_noop_when_not_found( def test_get_job_by_platform_or_id_numeric(fake_db_session, fake_processing_job_record): from app.services.processing import get_job_by_platform_or_id - with patch("app.services.processing.get_job_by_id", return_value=fake_processing_job_record) as mock_get_by_id: + with patch( + "app.services.processing.get_job_by_id", return_value=fake_processing_job_record + ) as mock_get_by_id: result = get_job_by_platform_or_id(fake_db_session, "123") assert result == fake_processing_job_record mock_get_by_id.assert_called_once_with(fake_db_session, 123) @@ -685,8 +687,13 @@ def test_get_job_by_platform_or_id_string(fake_db_session, fake_processing_job_r def test_update_job_status_from_callback_success(fake_db_session, fake_processing_job_record): from app.services.processing import update_job_status_from_callback - with patch("app.services.processing.get_job_by_platform_or_id", return_value=fake_processing_job_record), \ - patch("app.services.processing.update_job_status_by_id") as mock_update_db: + with ( + patch( + "app.services.processing.get_job_by_platform_or_id", + return_value=fake_processing_job_record, + ), + patch("app.services.processing.update_job_status_by_id") as mock_update_db, + ): result = update_job_status_from_callback( fake_db_session, "myns:wf-abc", ProcessingStatusEnum.FINISHED ) @@ -705,4 +712,3 @@ def test_update_job_status_from_callback_not_found(fake_db_session): fake_db_session, "nonexistent", ProcessingStatusEnum.RUNNING ) assert result is None - From 8edf771a3812b5e812e7c0e5cfe30ebad6a894e3 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 19 Oct 2026 12:35:59 +0200 Subject: [PATCH 10/14] refactor(callbacks): use typed problem exceptions - Upgrade eoap-problems-registry to 1.8.0 to use NotFoundException and ServerErrorException, which derive problem instance from Request. --- app/routers/unit_jobs.py | 49 +++++++++++------------ requirements.txt | 2 +- tests/routers/test_unit_jobs_callbacks.py | 2 +- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index c437f31..14d4dbc 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -13,7 +13,11 @@ ) from app.middleware.error_handling import get_dispatcher_error_response from eoap_problems_registry import ErrorDetail, NotFound, ServerError -from eoap_problems_registry.fastapi import ProblemRegistryException +from eoap_problems_registry.fastapi import ( + ServerErrorException, + NotFoundException, + ProblemRegistryException +) from stage_events_client.models import ( BaseCloudEvent, CompletedCloudEvent, @@ -314,8 +318,7 @@ def _handle_callback_status_update( ) -> None: """Internal helper to update job status from callback CloudEvents.""" job_identifier = _extract_job_identifier_from_cloudevent(cloud_event) or "" - instance_uri = request.url.path - endpoint_name = instance_uri.split("/")[-1] + endpoint_name = request.url.path.split("/")[-1] logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") @@ -331,33 +334,29 @@ def _handle_callback_status_update( ) return - raise ProblemRegistryException( - problem=NotFound( - instance=instance_uri, - errors=[ - ErrorDetail( - parameter="job_id", - detail=f"Job {job_identifier} does not exist in the Job registry", - ) - ], - ) - ) - except ProblemRegistryException: - raise - except Exception as e: - logger.error(f"Error handling {endpoint_name} callback for job {job_identifier}: {e}") - raise ProblemRegistryException( - problem=ServerError( - instance=instance_uri, + raise NotFoundException( + request=request, errors=[ ErrorDetail( parameter="job_id", - detail=( - f"An error occurred when updating status of Job {job_identifier}: {e}" - ), + detail=f"Job {job_identifier} does not exist in the Job registry", ) - ], + ] ) + except ProblemRegistryException: + raise + except Exception as e: + logger.error(f"Error handling {endpoint_name} callback for job {job_identifier}: {e}") + raise ServerErrorException( + request=request, + errors=[ + ErrorDetail( + parameter="job_id", + detail=( + f"An error occurred when updating status of Job {job_identifier}: {e}" + ), + ) + ] ) diff --git a/requirements.txt b/requirements.txt index e560bcb..557bfd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,5 +32,5 @@ types-requests types-shapely uvicorn[standard] ogc-api-processes-client==0.6.0 -eoap-problems-registry==1.7.0 +eoap-problems-registry==1.8.0 stage-events-client==1.1.0 diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index 17c1c17..8bb02d4 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -140,7 +140,7 @@ def test_callback_success_404_problem_details(mock_update, client): data = r.json() assert data["status"] == 404 assert data["title"] == "Not Found" - assert data["instance"] == "/unit_jobs/success" + assert data["instance"].endswith("/unit_jobs/success") assert data["errors"] == [ { "detail": "Job default:nonexistent does not exist in the Job registry", From 373a63cfb2fdb15a68e22f8a3c6682c543ae2f82 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Thu, 22 Oct 2026 10:59:19 +0200 Subject: [PATCH 11/14] refactor(api): migrate to http-apis-problems-detail Replace eoap-problems-registry with http-apis-problems-detail>=2, use PROBLEM_RESPONSES for callback error schemas, and update OpenAPI response references. --- app/middleware/error_handling.py | 2 +- app/routers/unit_jobs.py | 36 +++++++++++++++++--------------- app/schemas/callbacks.py | 2 +- openapi.json | 6 +++--- requirements.txt | 4 ++-- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/app/middleware/error_handling.py b/app/middleware/error_handling.py index 78e29d6..dd116b3 100644 --- a/app/middleware/error_handling.py +++ b/app/middleware/error_handling.py @@ -1,7 +1,7 @@ import traceback from typing import Any -from eoap_problems_registry.fastapi import ( +from http_apis_problems_detail.fastapi import ( ProblemRegistryException, problem_registry_exception_handler, ) diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index 14d4dbc..708bebe 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -12,8 +12,9 @@ JobNotFoundException, ) from app.middleware.error_handling import get_dispatcher_error_response -from eoap_problems_registry import ErrorDetail, NotFound, ServerError -from eoap_problems_registry.fastapi import ( +from http_apis_problems_detail import ErrorDetail +from http_apis_problems_detail.fastapi import ( + PROBLEM_RESPONSES, ServerErrorException, NotFoundException, ProblemRegistryException @@ -28,12 +29,7 @@ StagedCloudEvent, SubmittedCloudEvent, ) -from app.schemas.callbacks import ( - STAGE_TO_APEX_STATUS, - UnitJobsJobIdFailedPostResponse, - UnitJobsJobIdInProgressPostResponse, - UnitJobsJobIdSuccessPostResponse, -) +from app.schemas.callbacks import STAGE_TO_APEX_STATUS from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum, ProcessingStatusEnum from app.schemas.unit_job import ( BaseJobRequest, @@ -370,9 +366,11 @@ def _handle_callback_status_update( operation_id="onSuccessJob", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdSuccessPostResponse}, - status.HTTP_404_NOT_FOUND: {"model": NotFound}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), }, ) def on_success_job( @@ -399,9 +397,11 @@ def on_success_job( operation_id="onProgressJob", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdInProgressPostResponse}, - status.HTTP_404_NOT_FOUND: {"model": NotFound}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), }, ) def on_progress_job( @@ -431,9 +431,11 @@ def on_progress_job( operation_id="onFailedJob", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: {"model": UnitJobsJobIdFailedPostResponse}, - status.HTTP_404_NOT_FOUND: {"model": NotFound}, - status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ServerError}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), }, ) def on_failed_job( diff --git a/app/schemas/callbacks.py b/app/schemas/callbacks.py index e8ba98a..4e62302 100644 --- a/app/schemas/callbacks.py +++ b/app/schemas/callbacks.py @@ -1,7 +1,7 @@ from __future__ import annotations from pydantic import RootModel -from eoap_problems_registry import ( +from http_apis_problems_detail import ( BadRequest, InvalidBodyPropertyFormat, InvalidBodyPropertyValue, diff --git a/openapi.json b/openapi.json index fb67edf..a1e6187 100644 --- a/openapi.json +++ b/openapi.json @@ -2689,13 +2689,13 @@ "description": "Request has been processed and job status has been successfully updated.\n" }, "BadRequest": { - "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/BadRequest" + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/BadRequest" }, "NotFound": { - "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/NotFound" + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/NotFound" }, "ServerError": { - "$ref": "https://eoap.github.io/problems-registry/openapi.yaml#/components/responses/ServerError" + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/ServerError" } } } diff --git a/requirements.txt b/requirements.txt index 557bfd2..36b39cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,5 +32,5 @@ types-requests types-shapely uvicorn[standard] ogc-api-processes-client==0.6.0 -eoap-problems-registry==1.8.0 -stage-events-client==1.1.0 +http-apis-problems-detail>=2 +stage-events-client==1.3.0 From 080153b0eafe1f26eb679a4f0c715fae41c271a0 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Mon, 26 Oct 2026 10:57:16 +0100 Subject: [PATCH 12/14] feat(callbacks): validate status transitions Enforce state machine transitions on callback updates, returning 422 BusinessRuleViolationException for invalid state changes. --- app/routers/unit_jobs.py | 40 +++- app/services/processing.py | 18 -- app/services/processing_state_machine.py | 89 ++++++++ openapi.json | 3 + requirements.txt | 1 + tests/routers/test_unit_jobs_callbacks.py | 255 ++++++++++++---------- tests/services/test_processing.py | 30 --- 7 files changed, 265 insertions(+), 171 deletions(-) create mode 100644 app/services/processing_state_machine.py diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index 708bebe..d911e10 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -15,6 +15,7 @@ from http_apis_problems_detail import ErrorDetail from http_apis_problems_detail.fastapi import ( PROBLEM_RESPONSES, + BusinessRuleViolationException, ServerErrorException, NotFoundException, ProblemRegistryException @@ -42,9 +43,10 @@ delete_processing_job, get_processing_job_by_user_id, get_processing_job_results, - update_job_status_from_callback, + get_job_by_platform_or_id, + update_job_status_by_id ) - +from app.services.processing_state_machine import ProcessingStateMachine from stac_pydantic import Collection # from app.auth import get_current_user @@ -319,7 +321,7 @@ def _handle_callback_status_update( logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") try: - job = update_job_status_from_callback(db, job_identifier, target_status) + job = get_job_by_platform_or_id(db, job_identifier) if not job: if endpoint_name == "inProgress": # An early inProgress callback may arrive before initial job commit finishes; @@ -334,11 +336,32 @@ def _handle_callback_status_update( request=request, errors=[ ErrorDetail( - parameter="job_id", + pointer="subject", detail=f"Job {job_identifier} does not exist in the Job registry", ) ] ) + + if not ProcessingStateMachine.can_transition(job.status, target_status): + raise BusinessRuleViolationException( + request=request, + errors=[ + ErrorDetail( + pointer="subject", + detail=( + f"Job {job_identifier} can not transition from status " + f"{job.status} to {target_status}" + ), + ) + ] + ) + + update_job_status_by_id(db, job.id, target_status) + job.status = target_status + logger.info( + f"Successfully updated status of job '{job_identifier}' (id={job.id}) " + f"to {target_status}" + ) except ProblemRegistryException: raise except Exception as e: @@ -368,6 +391,9 @@ def _handle_callback_status_update( status.HTTP_200_OK: {"description": "Job status updated successfully."}, status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), status.HTTP_500_INTERNAL_SERVER_ERROR: ( PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] ), @@ -399,6 +425,9 @@ def on_success_job( status.HTTP_200_OK: {"description": "Job status updated successfully."}, status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), status.HTTP_500_INTERNAL_SERVER_ERROR: ( PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] ), @@ -433,6 +462,9 @@ def on_progress_job( status.HTTP_200_OK: {"description": "Job status updated successfully."}, status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), status.HTTP_500_INTERNAL_SERVER_ERROR: ( PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] ), diff --git a/app/services/processing.py b/app/services/processing.py index a0ecbba..0304dae 100644 --- a/app/services/processing.py +++ b/app/services/processing.py @@ -169,24 +169,6 @@ def get_job_by_platform_or_id( ) -def update_job_status_from_callback( - database: Session, job_identifier: str, status: ProcessingStatusEnum -) -> Optional[ProcessingJobRecord]: - logger.info( - f"Updating status of job '{job_identifier}' from callback to {status}" - ) - job = get_job_by_platform_or_id(database, job_identifier) - if not job: - return None - - update_job_status_by_id(database, job.id, status) - job.status = status - logger.info( - f"Successfully updated status of job '{job_identifier}' (id={job.id}) to {status}" - ) - return job - - async def get_processing_jobs_by_user_id( token: str, database: Session, upscaling_task_id: int | None = None ) -> List[ProcessingJobSummary]: diff --git a/app/services/processing_state_machine.py b/app/services/processing_state_machine.py new file mode 100644 index 0000000..8d295f1 --- /dev/null +++ b/app/services/processing_state_machine.py @@ -0,0 +1,89 @@ +"""Pure status validation; imports the existing APEx enum. + +No database writes or remote requests are performed. Confirmation flags must +come from trusted integration code, never directly from a callback payload. +""" + +from statemachine import State, StateMachine + +from app.schemas.enum import ProcessingStatusEnum as Status + + +class ProcessingStateMachine(StateMachine): + """Execution observations plus explicitly confirmed resource deletion. + + UNKNOWN describes missing knowledge, so it is handled by can_transition + rather than represented as an execution state. FINISHED/CANCELED/FAILED + and DELETED are final and have no outgoing transitions. + """ + + created = State(value=Status.CREATED, initial=True) + queued = State(value=Status.QUEUED) + running = State(value=Status.RUNNING) + finished = State(value=Status.FINISHED, final=True) + canceled = State(value=Status.CANCELED, final=True) + failed = State(value=Status.FAILED, final=True) + deleted = State(value=Status.DELETED, final=True) + + observe_queued = created.to(queued) + observe_running = created.to(running) | queued.to(running) + observe_finished = ( + created.to(finished) | queued.to(finished) | running.to(finished) + ) + observe_canceled = ( + created.to(canceled) | queued.to(canceled) | running.to(canceled) + ) + observe_failed = created.to(failed) | queued.to(failed) | running.to(failed) + observe_deleted = ( + created.to(deleted, cond="is_deletion_confirmed") + | queued.to(deleted, cond="is_deletion_confirmed") + | running.to(deleted, cond="is_deletion_confirmed") + ) + + @staticmethod + def is_deletion_confirmed(deletion_confirmed: bool = False) -> bool: + return deletion_confirmed + + @classmethod + def can_transition( + cls, + current_status: Status | str, + new_status: Status | str, + *, + authoritative: bool = False, + deletion_confirmed: bool = False, + ) -> bool: + """Check an update without changing a machine, model, or database. + + Accepts enum members or their serialized lowercase values. Invalid + values raise ValueError rather than being silently treated as UNKNOWN. + + True means the update is allowed, including an idempotent no-op. + False means preserve current_status. Incoming UNKNOWN is never allowed + to replace a known status. Resolving a stored UNKNOWN requires a + successful authoritative lookup for the same execution attempt. + + authoritative does not override known-state transition rules. + deletion_confirmed is required to enter DELETED, even from UNKNOWN. + Neither flag performs verification itself. + """ + current = Status(current_status) + target = Status(new_status) + + if current == target: + return True + if target == Status.UNKNOWN: + return False + if target == Status.DELETED and not deletion_confirmed: + return False + if current == Status.UNKNOWN: + return authoritative + + machine = cls(start_value=current) + event_name = f"observe_{target.value}" + return any( + event.id == event_name + for event in machine.enabled_events( + deletion_confirmed=deletion_confirmed, + ) + ) diff --git a/openapi.json b/openapi.json index a1e6187..8ca2038 100644 --- a/openapi.json +++ b/openapi.json @@ -2691,6 +2691,9 @@ "BadRequest": { "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/BadRequest" }, + "BusinessRuleViolation": { + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/BusinessRuleViolation" + }, "NotFound": { "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/NotFound" }, diff --git a/requirements.txt b/requirements.txt index 36b39cb..1056dd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,4 @@ uvicorn[standard] ogc-api-processes-client==0.6.0 http-apis-problems-detail>=2 stage-events-client==1.3.0 +python-statemachine==3.2.2 diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/routers/test_unit_jobs_callbacks.py index 8bb02d4..d247566 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/routers/test_unit_jobs_callbacks.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from fastapi import status @@ -99,154 +99,171 @@ # --- Inbound Callback Tests --- -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_success_piped_200(mock_update, client): - r = client.post( - "/unit_jobs/success", - json=PIPED_PAYLOAD, +@pytest.fixture +def callback_dependencies(fake_processing_job_record): + with ( + patch( + "app.routers.unit_jobs.get_job_by_platform_or_id", + return_value=fake_processing_job_record, + ) as lookup, + patch( + "app.routers.unit_jobs.ProcessingStateMachine.can_transition", + return_value=True, + ) as can_transition, + patch("app.routers.unit_jobs.update_job_status_by_id") as update, + ): + yield lookup, can_transition, update + + +CALLBACK_CASES = [ + pytest.param("success", PIPED_PAYLOAD, ProcessingStatusEnum.FINISHED, id="piped"), + pytest.param("success", COMPLETED_PAYLOAD, ProcessingStatusEnum.FINISHED, id="completed"), + pytest.param("inProgress", STAGED_PAYLOAD, ProcessingStatusEnum.RUNNING, id="staged"), + pytest.param("inProgress", PREPARED_PAYLOAD, ProcessingStatusEnum.RUNNING, id="prepared"), + pytest.param("inProgress", SUBMITTED_PAYLOAD, ProcessingStatusEnum.QUEUED, id="submitted"), + pytest.param("inProgress", DISMISSED_PAYLOAD, ProcessingStatusEnum.CANCELED, id="dismissed"), + pytest.param("failed", FAILED_PAYLOAD, ProcessingStatusEnum.FAILED, id="failed"), +] + + +@pytest.mark.parametrize("endpoint,payload,target_status", CALLBACK_CASES) +def test_callback_updates_job_status( + client, callback_dependencies, fake_processing_job_record, + endpoint, payload, target_status, +): + lookup, can_transition, update = callback_dependencies + initial_status = fake_processing_job_record.status + + response = client.post( + f"/unit_jobs/{endpoint}", + json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_200_OK - mock_update.assert_called_once() - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.FINISHED + assert response.status_code == status.HTTP_200_OK + lookup.assert_called_once_with(ANY, "default:job-123") + can_transition.assert_called_once_with(initial_status, target_status) + update.assert_called_once_with( + lookup.call_args.args[0], fake_processing_job_record.id, target_status + ) + assert fake_processing_job_record.status == target_status -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_success_completed_200(mock_update, client): - r = client.post( - "/unit_jobs/success", - json=COMPLETED_PAYLOAD, + +@pytest.mark.parametrize("endpoint,payload,target_status", CALLBACK_CASES) +def test_callback_rejects_invalid_transition( + client, callback_dependencies, fake_processing_job_record, + endpoint, payload, target_status, +): + lookup, can_transition, update = callback_dependencies + fake_processing_job_record.status = ProcessingStatusEnum.DELETED + can_transition.return_value = False + + response = client.post( + f"/unit_jobs/{endpoint}", + json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_200_OK - mock_update.assert_called_once() - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.FINISHED + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert response.headers["content-type"] == "application/problem+json" + data = response.json() + assert data["status"] == 422 + assert data["instance"].endswith(f"/unit_jobs/{endpoint}") + assert len(data["errors"]) == 1 + assert data["errors"][0]["pointer"] == "subject" + assert "default:job-123" in data["errors"][0]["detail"] + lookup.assert_called_once_with(ANY, "default:job-123") + can_transition.assert_called_once_with(ProcessingStatusEnum.DELETED, target_status) + update.assert_not_called() + assert fake_processing_job_record.status == ProcessingStatusEnum.DELETED + + +@pytest.mark.parametrize("endpoint,payload", [ + ("success", PIPED_PAYLOAD), + ("failed", FAILED_PAYLOAD), +]) +def test_callback_missing_job_404_problem_details( + client, callback_dependencies, endpoint, payload, +): + lookup, can_transition, update = callback_dependencies + lookup.return_value = None -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_success_404_problem_details(mock_update, client): - mock_update.return_value = None - r = client.post( - "/unit_jobs/success", - json={**PIPED_PAYLOAD, "subject": "default:test-uid:nonexistent"}, + response = client.post( + f"/unit_jobs/{endpoint}", + json={**payload, "subject": "default:test-uid:nonexistent"}, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_404_NOT_FOUND - assert r.headers.get("content-type") == "application/problem+json" - data = r.json() + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.headers["content-type"] == "application/problem+json" + data = response.json() assert data["status"] == 404 assert data["title"] == "Not Found" - assert data["instance"].endswith("/unit_jobs/success") + assert data["instance"].endswith(f"/unit_jobs/{endpoint}") assert data["errors"] == [ { "detail": "Job default:nonexistent does not exist in the Job registry", - "parameter": "job_id", + "pointer": "subject", } ] + lookup.assert_called_once_with(ANY, "default:nonexistent") + can_transition.assert_not_called() + update.assert_not_called() -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_staged_200(mock_update, client): - r = client.post( - "/unit_jobs/inProgress", - json=STAGED_PAYLOAD, - headers={"Content-Type": "application/cloudevents+json"}, - ) - assert r.status_code == status.HTTP_200_OK - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.RUNNING - - -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_prepared_200(mock_update, client): - r = client.post( - "/unit_jobs/inProgress", - json=PREPARED_PAYLOAD, - headers={"Content-Type": "application/cloudevents+json"}, - ) - assert r.status_code == status.HTTP_200_OK - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.RUNNING - +@pytest.mark.parametrize("payload", [ + SUBMITTED_PAYLOAD, PREPARED_PAYLOAD, STAGED_PAYLOAD, DISMISSED_PAYLOAD, +]) +def test_callback_in_progress_uncommitted_job_acknowledges_cleanly( + client, callback_dependencies, payload, +): + lookup, can_transition, update = callback_dependencies + lookup.return_value = None -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_submitted_200(mock_update, client): - r = client.post( + response = client.post( "/unit_jobs/inProgress", - json=SUBMITTED_PAYLOAD, + json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_200_OK - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.QUEUED + assert response.status_code == status.HTTP_200_OK + lookup.assert_called_once_with(ANY, "default:job-123") + can_transition.assert_not_called() + update.assert_not_called() -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_dismissed_200(mock_update, client): - r = client.post( - "/unit_jobs/inProgress", - json=DISMISSED_PAYLOAD, - headers={"Content-Type": "application/cloudevents+json"}, - ) - assert r.status_code == status.HTTP_200_OK - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.CANCELED +@pytest.mark.parametrize("failure_point", ["lookup", "transition", "update"]) +def test_callback_unexpected_error_returns_500( + client, callback_dependencies, fake_processing_job_record, failure_point, +): + lookup, can_transition, update = callback_dependencies + initial_status = fake_processing_job_record.status + dependencies = {"lookup": lookup, "transition": can_transition, "update": update} + dependencies[failure_point].side_effect = RuntimeError("callback failure") -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_failed_200(mock_update, client): - r = client.post( - "/unit_jobs/failed", - json=FAILED_PAYLOAD, - headers={"Content-Type": "application/cloudevents+json"}, - ) - assert r.status_code == status.HTTP_200_OK - args, _ = mock_update.call_args - assert args[1] == "default:job-123" - assert args[2] == ProcessingStatusEnum.FAILED - - -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_resolves_3part_subject_from_cloudevent(mock_update, client): - payload = { - **PIPED_PAYLOAD, - "source": "mmiraftab:opensartoolkit:stage-out", - "subject": "mmiraftab:test-uid:opensartoolkit-v2-2-1-p6ms6", - } - r = client.post( + response = client.post( "/unit_jobs/success", - json=payload, - headers={"Content-Type": "application/cloudevents+json"}, - ) - assert r.status_code == status.HTTP_200_OK - mock_update.assert_called_once() - args, _ = mock_update.call_args - assert args[1] == "mmiraftab:opensartoolkit-v2-2-1-p6ms6" - assert args[2] == ProcessingStatusEnum.FINISHED - - -@patch("app.routers.unit_jobs.update_job_status_from_callback") -def test_callback_in_progress_uncommitted_job_acknowledges_cleanly(mock_update, client): - mock_update.return_value = None # simulate job not committed yet - payload = { - **SUBMITTED_PAYLOAD, - "source": "mmiraftab:opensartoolkit:main", - "subject": "mmiraftab:test-uid:opensartoolkit-v2-2-1-p6ms6", - } - r = client.post( - "/unit_jobs/inProgress", - json=payload, + json=PIPED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) - assert r.status_code == status.HTTP_200_OK + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.headers["content-type"] == "application/problem+json" + data = response.json() + assert data["status"] == 500 + assert data["instance"].endswith("/unit_jobs/success") + assert data["errors"] == [{ + "parameter": "job_id", + "detail": ( + "An error occurred when updating status of Job default:job-123: " + "callback failure" + ), + }] + assert fake_processing_job_record.status == initial_status + if failure_point != "update": + update.assert_not_called() + if failure_point == "lookup": + can_transition.assert_not_called() def test_extract_job_identifier_from_cloudevent(): diff --git a/tests/services/test_processing.py b/tests/services/test_processing.py index 0750ba1..3d47566 100644 --- a/tests/services/test_processing.py +++ b/tests/services/test_processing.py @@ -682,33 +682,3 @@ def test_get_job_by_platform_or_id_string(fake_db_session, fake_processing_job_r result = get_job_by_platform_or_id(fake_db_session, "myns:uid-1:wf-abc") assert result == fake_processing_job_record fake_db_session.query.assert_called_once() - - -def test_update_job_status_from_callback_success(fake_db_session, fake_processing_job_record): - from app.services.processing import update_job_status_from_callback - - with ( - patch( - "app.services.processing.get_job_by_platform_or_id", - return_value=fake_processing_job_record, - ), - patch("app.services.processing.update_job_status_by_id") as mock_update_db, - ): - result = update_job_status_from_callback( - fake_db_session, "myns:wf-abc", ProcessingStatusEnum.FINISHED - ) - assert result == fake_processing_job_record - assert result.status == ProcessingStatusEnum.FINISHED - mock_update_db.assert_called_once_with( - fake_db_session, fake_processing_job_record.id, ProcessingStatusEnum.FINISHED - ) - - -def test_update_job_status_from_callback_not_found(fake_db_session): - from app.services.processing import update_job_status_from_callback - - with patch("app.services.processing.get_job_by_platform_or_id", return_value=None): - result = update_job_status_from_callback( - fake_db_session, "nonexistent", ProcessingStatusEnum.RUNNING - ) - assert result is None From 1243f95e1ffb2f748920576b488d20457c438831 Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Wed, 28 Oct 2026 11:54:15 +0100 Subject: [PATCH 13/14] refactor(callbacks): isolate GEP callback routes Move GEP CloudEvent callback endpoints to dedicated integration routes, enforcing exact job correlation and shared lifecycle validation. --- app/main.py | 3 + app/platforms/implementations/gep/__init__.py | 1 + .../implementations/gep/callbacks.py | 49 ++++ app/platforms/implementations/gep/router.py | 184 +++++++++++++++ app/platforms/implementations/gep/schemas.py | 12 + .../implementations/gep/subscriber.py | 12 + .../implementations/ogc_api_process.py | 15 +- app/routers/unit_jobs.py | 212 +----------------- app/schemas/callbacks.py | 136 ----------- app/services/job_status.py | 28 +++ app/services/processing.py | 24 -- docs/configuration.md | 39 +++- openapi.json | 30 ++- .../integrations/gep/test_integration.py | 97 ++++++++ .../integrations/gep/test_router.py} | 47 ++-- .../test_ogc_api_process_platform.py | 36 ++- tests/services/test_processing.py | 25 --- 17 files changed, 504 insertions(+), 446 deletions(-) create mode 100644 app/platforms/implementations/gep/__init__.py create mode 100644 app/platforms/implementations/gep/callbacks.py create mode 100644 app/platforms/implementations/gep/router.py create mode 100644 app/platforms/implementations/gep/schemas.py create mode 100644 app/platforms/implementations/gep/subscriber.py delete mode 100644 app/schemas/callbacks.py create mode 100644 app/services/job_status.py create mode 100644 tests/platforms/integrations/gep/test_integration.py rename tests/{routers/test_unit_jobs_callbacks.py => platforms/integrations/gep/test_router.py} (86%) diff --git a/app/main.py b/app/main.py index 5a596a7..fae83a3 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ from app.middleware.correlation_id import add_correlation_id from app.middleware.error_handling import register_exception_handlers from app.platforms.dispatcher import load_processing_platforms +from app.platforms.implementations.gep.router import router as gep_callbacks_router from app.services.tiles.base import load_grids from app.config.logger import setup_logging from app.config.settings import settings @@ -59,3 +60,5 @@ async def root(): app.include_router(health.router) app.include_router(parameters.router) app.include_router(statistics.router) + +app.include_router(gep_callbacks_router) diff --git a/app/platforms/implementations/gep/__init__.py b/app/platforms/implementations/gep/__init__.py new file mode 100644 index 0000000..6e6aa00 --- /dev/null +++ b/app/platforms/implementations/gep/__init__.py @@ -0,0 +1 @@ +"""GEP stage-event callback integration for OGC API Processes.""" diff --git a/app/platforms/implementations/gep/callbacks.py b/app/platforms/implementations/gep/callbacks.py new file mode 100644 index 0000000..86e5059 --- /dev/null +++ b/app/platforms/implementations/gep/callbacks.py @@ -0,0 +1,49 @@ +"""GEP event interpretation and exact platform-job correlation.""" + +from sqlalchemy.orm import Session +from stage_events_client.models import BaseCloudEvent + +from app.database.models.processing_job import ProcessingJobRecord +from app.schemas.enum import ProcessTypeEnum, ProcessingStatusEnum + +STAGE_TO_APEX_STATUS: dict[str, ProcessingStatusEnum] = { + "submitted": ProcessingStatusEnum.QUEUED, + "prepared": ProcessingStatusEnum.RUNNING, + "staged": ProcessingStatusEnum.RUNNING, + "piped": ProcessingStatusEnum.FINISHED, + "completed": ProcessingStatusEnum.FINISHED, + "failed": ProcessingStatusEnum.FAILED, + "dismissed": ProcessingStatusEnum.CANCELED, +} + + +def extract_job_identifier(cloud_event: BaseCloudEvent) -> str | None: + """Extract job_identifier strictly from CloudEvent subject metadata.""" + # CloudEvent subject pattern: "::" + subject = getattr(cloud_event, "subject", None) + if not subject: + return None + + parts = subject.split(":") + if len(parts) == 3: + return f"{parts[0]}:{parts[2]}" + return subject + + +def get_gep_job(database: Session, job_identifier: str) -> ProcessingJobRecord | None: + """Never interpret a GEP identifier as an APEx ID or use suffix matching. + + Subjects carry no trusted backend identity. If multiple OGC jobs have + overlapping identifiers, reject the event instead of picking the first job. + """ + matches = ( + database.query(ProcessingJobRecord) + .filter( + ProcessingJobRecord.label == ProcessTypeEnum.OGC_API_PROCESS, + ProcessingJobRecord.platform_job_id == job_identifier, + ) + .all() + ) + if len(matches) > 1: + raise ValueError("Ambiguous GEP job identifier") + return matches[0] if matches else None diff --git a/app/platforms/implementations/gep/router.py b/app/platforms/implementations/gep/router.py new file mode 100644 index 0000000..0336462 --- /dev/null +++ b/app/platforms/implementations/gep/router.py @@ -0,0 +1,184 @@ +"""Receive GEP stage CloudEvents, not arbitrary OGC callback payloads.""" + +from fastapi import APIRouter, Depends, Request, status +from loguru import logger +from sqlalchemy.orm import Session +from stage_events_client.models import BaseCloudEvent +from http_apis_problems_detail import ErrorDetail +from http_apis_problems_detail.fastapi import ( + PROBLEM_RESPONSES, BusinessRuleViolationException, ServerErrorException, + NotFoundException, ProblemRegistryException, +) + +from app.database.db import get_db +from app.schemas.enum import ProcessingStatusEnum +from app.services.job_status import apply_job_status, InvalidJobTransition +from app.platforms.implementations.gep.callbacks import ( + STAGE_TO_APEX_STATUS, extract_job_identifier, get_gep_job, +) +from app.platforms.implementations.gep.schemas import ( + ProgressEvent, SuccessEvent, FailureEvent, +) +from app.platforms.implementations.gep.subscriber import CALLBACK_PREFIX + +router = APIRouter(prefix=CALLBACK_PREFIX, tags=["GEP callbacks"]) + + +def _handle_callback_status_update( + db: Session, + cloud_event: BaseCloudEvent, + target_status: ProcessingStatusEnum, + request: Request, +) -> None: + """Internal helper to update job status from callback CloudEvents.""" + job_identifier = extract_job_identifier(cloud_event) or "" + endpoint_name = request.url.path.split("/")[-1] + + logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") + + try: + job = get_gep_job(db, job_identifier) + if not job: + if endpoint_name == "inProgress": + # An early inProgress callback may arrive before initial job commit finishes; + # acknowledge gracefully without returning 404. + logger.debug( + f"Job '{job_identifier}' is not registered in database yet; " + "acknowledging in progress" + ) + return + + raise NotFoundException( + request=request, + errors=[ + ErrorDetail( + pointer="subject", + detail=f"Job {job_identifier} does not exist in the Job registry", + ) + ] + ) + + try: + apply_job_status(db, job, target_status) + except InvalidJobTransition as exc: + raise BusinessRuleViolationException( + request=request, + errors=[ErrorDetail(pointer="subject", detail=str(exc))], + ) from exc + logger.info( + f"Successfully updated status of job '{job_identifier}' (id={job.id}) " + f"to {target_status}" + ) + except ProblemRegistryException: + raise + except Exception as e: + logger.error(f"Error handling {endpoint_name} callback for job {job_identifier}: {e}") + raise ServerErrorException( + request=request, + errors=[ + ErrorDetail( + parameter="job_id", + detail=( + f"An error occurred when updating status of Job {job_identifier}: {e}" + ), + ) + ] + ) + + +@router.post( + "/success", + response_model=None, + status_code=status.HTTP_200_OK, + summary="URIs for success callbacks for jobs.", + description=( + "Accepts GEP stage CloudEvents. The callback URL will be invoked " + "after a job succeeded its execution.\n" + ), + operation_id="onGepJobSuccess", + responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), + }, +) +def on_gep_job_success( + request: Request, + body: SuccessEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for success callbacks for jobs.""" + _handle_callback_status_update( + db, body, ProcessingStatusEnum.FINISHED, request + ) + + +@router.post( + "/inProgress", + response_model=None, + status_code=status.HTTP_200_OK, + summary="URIs for progress callbacks for jobs.", + description=( + "Accepts GEP stage CloudEvents. The callback URL will be invoked while a job is changing " + "its status during its execution.\n" + ), + operation_id="onGepJobProgress", + responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), + }, +) +def on_gep_job_progress( + request: Request, + body: ProgressEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for progress callbacks for jobs.""" + target_status = STAGE_TO_APEX_STATUS[body.type] + _handle_callback_status_update(db, body, target_status, request) + + +@router.post( + "/failed", + response_model=None, + status_code=status.HTTP_200_OK, + summary="URIs for failed callbacks for jobs.", + description=( + "Accepts GEP stage CloudEvents. The callback URL will be invoked " + "after a job failed its execution.\n" + ), + operation_id="onGepJobFailed", + responses={ + status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], + status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], + status.HTTP_422_UNPROCESSABLE_CONTENT: ( + PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] + ), + status.HTTP_500_INTERNAL_SERVER_ERROR: ( + PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] + ), + }, +) +def on_gep_job_failed( + request: Request, + body: FailureEvent, + db: Session = Depends(get_db), +) -> None: + """URIs for failed callbacks for jobs.""" + _handle_callback_status_update( + db, body, ProcessingStatusEnum.FAILED, request + ) diff --git a/app/platforms/implementations/gep/schemas.py b/app/platforms/implementations/gep/schemas.py new file mode 100644 index 0000000..456aa37 --- /dev/null +++ b/app/platforms/implementations/gep/schemas.py @@ -0,0 +1,12 @@ +"""Payload models accepted by the GEP callback integration.""" + +from stage_events_client.models import ( + CompletedCloudEvent, DismissedCloudEvent, FailedCloudEvent, + PipedCloudEvent, PreparedCloudEvent, StagedCloudEvent, SubmittedCloudEvent, +) + +ProgressEvent = ( + SubmittedCloudEvent | DismissedCloudEvent | PreparedCloudEvent | StagedCloudEvent +) +SuccessEvent = PipedCloudEvent | CompletedCloudEvent +FailureEvent = FailedCloudEvent diff --git a/app/platforms/implementations/gep/subscriber.py b/app/platforms/implementations/gep/subscriber.py new file mode 100644 index 0000000..2b37269 --- /dev/null +++ b/app/platforms/implementations/gep/subscriber.py @@ -0,0 +1,12 @@ +"""GEP callback URLs hosted by the existing APEx Dispatcher.""" + +CALLBACK_PREFIX = "/integrations/ogc-api-processes/gep/callbacks" + + +def build_subscriber(base_url: str) -> dict[str, str]: + prefix = f"{base_url.rstrip('/')}{CALLBACK_PREFIX}" + return { + "successUri": f"{prefix}/success", + "inProgressUri": f"{prefix}/inProgress", + "failedUri": f"{prefix}/failed", + } diff --git a/app/platforms/implementations/ogc_api_process.py b/app/platforms/implementations/ogc_api_process.py index d54b6da..2d4039d 100644 --- a/app/platforms/implementations/ogc_api_process.py +++ b/app/platforms/implementations/ogc_api_process.py @@ -5,6 +5,7 @@ import requests from app.auth import exchange_token, get_current_user_claims from app.config.settings import settings +from app.platforms.implementations.gep.subscriber import build_subscriber from fastapi import Response from loguru import logger @@ -211,18 +212,8 @@ async def execute_job( if user_claims.get("email"): properties["email"] = user_claims["email"] - base_url = settings.dispatch_api_public_url.rstrip("/") - subscriber = { - "successUri": f"{base_url}/unit_jobs/success", - "inProgressUri": f"{base_url}/unit_jobs/inProgress", - "failedUri": f"{base_url}/unit_jobs/failed", - } - - data = { - "inputs": parameters, - "properties": properties, - "subscriber": subscriber, - } + data = {"inputs": parameters, "properties": properties} + data["subscriber"] = build_subscriber(settings.dispatch_api_public_url) content = api_client.execute_simple( process_id=details.application, execute=data, _headers=headers diff --git a/app/routers/unit_jobs.py b/app/routers/unit_jobs.py index d911e10..04395ae 100644 --- a/app/routers/unit_jobs.py +++ b/app/routers/unit_jobs.py @@ -1,5 +1,5 @@ from typing import Annotated -from fastapi import Body, APIRouter, Depends, Request, status +from fastapi import Body, APIRouter, Depends, status from loguru import logger from sqlalchemy.orm import Session @@ -12,26 +12,7 @@ JobNotFoundException, ) from app.middleware.error_handling import get_dispatcher_error_response -from http_apis_problems_detail import ErrorDetail -from http_apis_problems_detail.fastapi import ( - PROBLEM_RESPONSES, - BusinessRuleViolationException, - ServerErrorException, - NotFoundException, - ProblemRegistryException -) -from stage_events_client.models import ( - BaseCloudEvent, - CompletedCloudEvent, - DismissedCloudEvent, - FailedCloudEvent, - PipedCloudEvent, - PreparedCloudEvent, - StagedCloudEvent, - SubmittedCloudEvent, -) -from app.schemas.callbacks import STAGE_TO_APEX_STATUS -from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum, ProcessingStatusEnum +from app.schemas.enum import OutputFormatEnum, ProcessTypeEnum from app.schemas.unit_job import ( BaseJobRequest, ProcessingJob, @@ -43,10 +24,7 @@ delete_processing_job, get_processing_job_by_user_id, get_processing_job_results, - get_job_by_platform_or_id, - update_job_status_by_id ) -from app.services.processing_state_machine import ProcessingStateMachine from stac_pydantic import Collection # from app.auth import get_current_user @@ -293,189 +271,3 @@ async def delete_job( message="An error occurred while deleting the processing job.", details={"error": str(e)} ) - - -def _extract_job_identifier_from_cloudevent(cloud_event: BaseCloudEvent) -> str | None: - """Extract job_identifier strictly from CloudEvent subject metadata.""" - # CloudEvent subject pattern: "::" - subject = getattr(cloud_event, "subject", None) - if not subject: - return None - - parts = subject.split(":") - if len(parts) == 3: - return f"{parts[0]}:{parts[2]}" - return subject - - -def _handle_callback_status_update( - db: Session, - cloud_event: BaseCloudEvent, - target_status: ProcessingStatusEnum, - request: Request, -) -> None: - """Internal helper to update job status from callback CloudEvents.""" - job_identifier = _extract_job_identifier_from_cloudevent(cloud_event) or "" - endpoint_name = request.url.path.split("/")[-1] - - logger.info(f"Callback '{endpoint_name}' received for job '{job_identifier}'") - - try: - job = get_job_by_platform_or_id(db, job_identifier) - if not job: - if endpoint_name == "inProgress": - # An early inProgress callback may arrive before initial job commit finishes; - # acknowledge gracefully without returning 404. - logger.debug( - f"Job '{job_identifier}' is not registered in database yet; " - "acknowledging in progress" - ) - return - - raise NotFoundException( - request=request, - errors=[ - ErrorDetail( - pointer="subject", - detail=f"Job {job_identifier} does not exist in the Job registry", - ) - ] - ) - - if not ProcessingStateMachine.can_transition(job.status, target_status): - raise BusinessRuleViolationException( - request=request, - errors=[ - ErrorDetail( - pointer="subject", - detail=( - f"Job {job_identifier} can not transition from status " - f"{job.status} to {target_status}" - ), - ) - ] - ) - - update_job_status_by_id(db, job.id, target_status) - job.status = target_status - logger.info( - f"Successfully updated status of job '{job_identifier}' (id={job.id}) " - f"to {target_status}" - ) - except ProblemRegistryException: - raise - except Exception as e: - logger.error(f"Error handling {endpoint_name} callback for job {job_identifier}: {e}") - raise ServerErrorException( - request=request, - errors=[ - ErrorDetail( - parameter="job_id", - detail=( - f"An error occurred when updating status of Job {job_identifier}: {e}" - ), - ) - ] - ) - - -@router.post( - "/unit_jobs/success", - response_model=None, - status_code=status.HTTP_200_OK, - tags=["Unit Jobs"], - summary="URIs for success callbacks for jobs.", - description="The callback URL will be invoked after a job succeeded its execution.\n", - operation_id="onSuccessJob", - responses={ - status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], - status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], - status.HTTP_422_UNPROCESSABLE_CONTENT: ( - PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] - ), - status.HTTP_500_INTERNAL_SERVER_ERROR: ( - PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] - ), - }, -) -def on_success_job( - request: Request, - body: PipedCloudEvent | CompletedCloudEvent, - db: Session = Depends(get_db), -) -> None: - """URIs for success callbacks for jobs.""" - _handle_callback_status_update( - db, body, ProcessingStatusEnum.FINISHED, request - ) - - -@router.post( - "/unit_jobs/inProgress", - response_model=None, - status_code=status.HTTP_200_OK, - tags=["Unit Jobs"], - summary="URIs for progress callbacks for jobs.", - description=( - "The callback URL will be invoked while a job is changing " - "its status during its execution.\n" - ), - operation_id="onProgressJob", - responses={ - status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], - status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], - status.HTTP_422_UNPROCESSABLE_CONTENT: ( - PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] - ), - status.HTTP_500_INTERNAL_SERVER_ERROR: ( - PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] - ), - }, -) -def on_progress_job( - request: Request, - body: ( - SubmittedCloudEvent - | DismissedCloudEvent - | PreparedCloudEvent - | StagedCloudEvent - ), - db: Session = Depends(get_db), -) -> None: - """URIs for progress callbacks for jobs.""" - target_status = STAGE_TO_APEX_STATUS.get( - body.type, ProcessingStatusEnum.RUNNING - ) - _handle_callback_status_update(db, body, target_status, request) - - -@router.post( - "/unit_jobs/failed", - response_model=None, - status_code=status.HTTP_200_OK, - tags=["Unit Jobs"], - summary="URIs for failed callbacks for jobs.", - description="The callback URL will be invoked after a job failed its execution.\n", - operation_id="onFailedJob", - responses={ - status.HTTP_200_OK: {"description": "Job status updated successfully."}, - status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], - status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], - status.HTTP_422_UNPROCESSABLE_CONTENT: ( - PROBLEM_RESPONSES[status.HTTP_422_UNPROCESSABLE_CONTENT] - ), - status.HTTP_500_INTERNAL_SERVER_ERROR: ( - PROBLEM_RESPONSES[status.HTTP_500_INTERNAL_SERVER_ERROR] - ), - }, -) -def on_failed_job( - request: Request, - body: FailedCloudEvent, - db: Session = Depends(get_db), -) -> None: - """URIs for failed callbacks for jobs.""" - _handle_callback_status_update( - db, body, ProcessingStatusEnum.FAILED, request - ) diff --git a/app/schemas/callbacks.py b/app/schemas/callbacks.py deleted file mode 100644 index 4e62302..0000000 --- a/app/schemas/callbacks.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -from pydantic import RootModel -from http_apis_problems_detail import ( - BadRequest, - InvalidBodyPropertyFormat, - InvalidBodyPropertyValue, - InvalidParameters, - InvalidRequestHeaderFormat, - InvalidRequestParameterFormat, - InvalidRequestParameterValue, - MissingBodyProperty, - MissingRequestHeader, - MissingRequestParameter, -) -from stage_events_client.models import ( - SubmittedCloudEvent, - DismissedCloudEvent, - PreparedCloudEvent, - CompletedCloudEvent, - StagedCloudEvent, -) - -from app.schemas.enum import ProcessingStatusEnum - -STAGE_TO_APEX_STATUS: dict[str, ProcessingStatusEnum] = { - "submitted": ProcessingStatusEnum.QUEUED, - "prepared": ProcessingStatusEnum.RUNNING, - "staged": ProcessingStatusEnum.RUNNING, - "piped": ProcessingStatusEnum.FINISHED, - "completed": ProcessingStatusEnum.FINISHED, - "failed": ProcessingStatusEnum.FAILED, - "dismissed": ProcessingStatusEnum.CANCELED, -} - - -class UnitJobsJobIdSuccessPostResponse( - RootModel[ - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ] -): - root: ( - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ) - - -class UnitJobsJobIdInProgressPostRequest( - RootModel[ - SubmittedCloudEvent - | DismissedCloudEvent - | PreparedCloudEvent - | CompletedCloudEvent - | StagedCloudEvent - ] -): - root: ( - SubmittedCloudEvent - | DismissedCloudEvent - | PreparedCloudEvent - | CompletedCloudEvent - | StagedCloudEvent - ) - - -class UnitJobsJobIdInProgressPostResponse( - RootModel[ - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ] -): - root: ( - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ) - - -class UnitJobsJobIdFailedPostResponse( - RootModel[ - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ] -): - root: ( - BadRequest - | InvalidBodyPropertyFormat - | InvalidBodyPropertyValue - | InvalidParameters - | InvalidRequestHeaderFormat - | InvalidRequestParameterFormat - | InvalidRequestParameterValue - | MissingBodyProperty - | MissingRequestHeader - | MissingRequestParameter - ) diff --git a/app/services/job_status.py b/app/services/job_status.py new file mode 100644 index 0000000..a49932c --- /dev/null +++ b/app/services/job_status.py @@ -0,0 +1,28 @@ +"""Platform-independent status updates for already-resolved jobs.""" + +from sqlalchemy.orm import Session + +from app.database.models.processing_job import ( + ProcessingJobRecord, update_job_status_by_id, +) +from app.schemas.enum import ProcessingStatusEnum +from app.services.processing_state_machine import ProcessingStateMachine + + +class InvalidJobTransition(ValueError): + """The requested status would violate the job lifecycle.""" + + +def apply_job_status( + database: Session, + job: ProcessingJobRecord, + target_status: ProcessingStatusEnum, +) -> None: + if not ProcessingStateMachine.can_transition(job.status, target_status): + raise InvalidJobTransition( + f"Job {job.platform_job_id} can not transition from status " + f"{job.status} to {target_status}" + ) + if job.status != target_status: + update_job_status_by_id(database, job.id, target_status) + job.status = target_status diff --git a/app/services/processing.py b/app/services/processing.py index 0304dae..15ed59c 100644 --- a/app/services/processing.py +++ b/app/services/processing.py @@ -10,7 +10,6 @@ from app.auth import get_current_user_id from app.database.models.processing_job import ( ProcessingJobRecord, - get_job_by_id, get_job_by_user_id, get_jobs_by_user_id, remove_job_by_id, @@ -146,29 +145,6 @@ async def _refresh_job_status( return record -def get_job_by_platform_or_id( - database: Session, job_identifier: str -) -> Optional[ProcessingJobRecord]: - if job_identifier.isdigit(): - return get_job_by_id(database, int(job_identifier)) - - candidates = [job_identifier] - parts = job_identifier.split(":") - if len(parts) == 3: - # e.g. "::" -> ":" - candidates.append(f"{parts[0]}:{parts[2]}") - candidates.append(parts[-1]) - - return ( - database.query(ProcessingJobRecord) - .filter( - ProcessingJobRecord.platform_job_id.in_(candidates) - | ProcessingJobRecord.platform_job_id.endswith(f":{parts[-1]}") - ) - .first() - ) - - async def get_processing_jobs_by_user_id( token: str, database: Session, upscaling_task_id: int | None = None ) -> List[ProcessingJobSummary]: diff --git a/docs/configuration.md b/docs/configuration.md index 70cb4e2..05b7225 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,4 +67,41 @@ KEYCLOAK_CLIENT_SECRET=apex-client-secret BACKENDS='{"https://openeo.backend1.com" {"auth_method": "CLIENT_CREDENTIALS", "client_credentials": "oidc_provider/client_id/secret_secret"}, "https://openeo.backend2.com" {"auth_method": "USER_CREDENTIALS", "token_provider": "backend", "token_prefix": "oidc/backend"}}' -``` \ No newline at end of file +``` + +## GEP callbacks for OGC API Processes + +GEP callback routes run inside the existing APEx Dispatcher. OGC job execution +continues to attach a subscriber block automatically, using the existing +`DISPATCH_API_PUBLIC_URL` setting. No additional backend configuration is required. + +The callback URLs are rooted at `DISPATCH_API_PUBLIC_URL`: + +- `/integrations/ogc-api-processes/gep/callbacks/inProgress` +- `/integrations/ogc-api-processes/gep/callbacks/success` +- `/integrations/ogc-api-processes/gep/callbacks/failed` + +These endpoints accept GEP stage CloudEvents, not arbitrary OGC API Processes +notification payloads. Routes are registered explicitly in `app/main.py`; +`app/platforms/implementations/gep/__init__.py` has no registration side effects. + +The GEP adapter owns event mapping, subject normalization and subscriber URLs. +It resolves exact platform job identifiers for OGC jobs; +it does not accept APEx database IDs or suffix matches. Multiple matching jobs +are rejected rather than arbitrarily selecting a backend. The existing subject +format does not identify a trusted backend; backend authentication/correlation +requires a separate change. This refactor does not implement callback authentication. + +The shared `app/services/job_status.py` validates and persists status changes. +Duplicate statuses are no-ops; the existing state machine rejects terminal-state +regressions. Existing polling behavior is unchanged. Missing jobs on progress +callbacks still receive 200, preserving the existing early-callback behavior; +missing jobs on success/failure receive 404. + +### Migration + +This change removes the three `/unit_jobs/{inProgress,success,failed}` callback +routes. Finish jobs +already subscribed to old URLs before deployment, or provide temporary explicit +reverse-proxy rewrites to the new URLs until those jobs complete. No database +migration or new `ProcessTypeEnum` value is required. diff --git a/openapi.json b/openapi.json index 8ca2038..329d43f 100644 --- a/openapi.json +++ b/openapi.json @@ -712,11 +712,12 @@ } } }, - "/unit_jobs/success": { + "/integrations/ogc-api-processes/gep/callbacks/success": { "post": { - "operationId": "onSuccessJob", + "operationId": "onGepJobSuccess", + "tags": ["GEP callbacks"], "summary": "URIs for success callbacks for jobs.", - "description": "The callback URL will be invoked after a job succeeded its execution.\n", + "description": "Accepts GEP stage CloudEvents. The callback URL will be invoked after a job succeeded its execution.\n", "requestBody": { "$ref": "#/components/requestBodies/SuccessRequestBody" }, @@ -730,17 +731,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "422": { + "$ref": "#/components/responses/BusinessRuleViolation" + }, "500": { "$ref": "#/components/responses/ServerError" } } } }, - "/unit_jobs/inProgress": { + "/integrations/ogc-api-processes/gep/callbacks/inProgress": { "post": { - "operationId": "onProgressJob", + "operationId": "onGepJobProgress", + "tags": ["GEP callbacks"], "summary": "URIs for progress callbacks for jobs.", - "description": "The callback URL will be invoked while a job is changing its status during its execution.\n", + "description": "Accepts GEP stage CloudEvents. The callback URL will be invoked while a job is changing its status during its execution.\n", "requestBody": { "$ref": "#/components/requestBodies/InProgressRequestBody" }, @@ -754,17 +759,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "422": { + "$ref": "#/components/responses/BusinessRuleViolation" + }, "500": { "$ref": "#/components/responses/ServerError" } } } }, - "/unit_jobs/failed": { + "/integrations/ogc-api-processes/gep/callbacks/failed": { "post": { - "operationId": "onFailedJob", + "operationId": "onGepJobFailed", + "tags": ["GEP callbacks"], "summary": "URIs for failed callbacks for jobs.", - "description": "The callback URL will be invoked after a job failed its execution.\n", + "description": "Accepts GEP stage CloudEvents. The callback URL will be invoked after a job failed its execution.\n", "requestBody": { "$ref": "#/components/requestBodies/FailedRequestBody" }, @@ -778,6 +787,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "422": { + "$ref": "#/components/responses/BusinessRuleViolation" + }, "500": { "$ref": "#/components/responses/ServerError" } diff --git a/tests/platforms/integrations/gep/test_integration.py b/tests/platforms/integrations/gep/test_integration.py new file mode 100644 index 0000000..e3cd533 --- /dev/null +++ b/tests/platforms/integrations/gep/test_integration.py @@ -0,0 +1,97 @@ +"""GEP job correlation, subscriber URLs, and shared lifecycle regressions.""" + +import json +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + +from app.database.models.processing_job import ProcessingJobRecord +from app.platforms.implementations.gep.callbacks import get_gep_job +from app.platforms.implementations.gep.subscriber import ( + CALLBACK_PREFIX, build_subscriber, +) +from app.schemas.enum import ProcessingStatusEnum as Status +from app.services.job_status import apply_job_status, InvalidJobTransition + + +def test_subscriber_uses_dispatcher_url(): + expected = { + "successUri": "https://dispatch.example" + CALLBACK_PREFIX + "/success", + "inProgressUri": "https://dispatch.example" + CALLBACK_PREFIX + "/inProgress", + "failedUri": "https://dispatch.example" + CALLBACK_PREFIX + "/failed", + } + assert build_subscriber('https://dispatch.example/') == expected + assert build_subscriber('https://dispatch.example') == expected + + +@pytest.fixture +def jobs_database(): + engine = create_engine('sqlite:///:memory:') + # Portable fixture for the MySQL LONGTEXT-backed production ORM model. + with engine.begin() as conn: + conn.execute(text('''CREATE TABLE processing_jobs ( + id INTEGER PRIMARY KEY, title TEXT, label TEXT, status TEXT, + user_id TEXT, platform_job_id TEXT, parameters TEXT, service TEXT, + created DATETIME, updated DATETIME, upscaling_task_id INTEGER + )''')) + rows = [ + (1, 'OPENEO', 'ns:job', 'https://gep.example'), + (2, 'OGC_API_PROCESS', 'ns:other-job', 'https://other.example'), + (3, 'OGC_API_PROCESS', 'other:job', 'https://gep.example'), + (4, 'OGC_API_PROCESS', 'ns:job', 'https://gep.example'), + (5, 'OGC_API_PROCESS', '123', 'https://gep.example'), + ] + for job_id, label, platform_id, endpoint in rows: + conn.execute(text('''INSERT INTO processing_jobs + (id, label, status, platform_job_id, service) + VALUES (:id, :label, 'RUNNING', :platform_id, :service)'''), + dict(id=job_id, label=label, platform_id=platform_id, + service=json.dumps(dict(endpoint=endpoint, application='app')))) + with Session(engine) as session: + yield session + engine.dispose() + + +def test_lookup_is_exact_and_scoped_to_ogc(jobs_database): + assert get_gep_job(jobs_database, 'ns:job').id == 4 + assert get_gep_job(jobs_database, 'ns:other-job').id == 2 + assert get_gep_job(jobs_database, 'job') is None + assert get_gep_job(jobs_database, '4') is None + assert get_gep_job(jobs_database, '123').id == 5 + + +def test_lookup_rejects_ambiguity(jobs_database): + jobs_database.execute(text('''INSERT INTO processing_jobs + (id, label, status, platform_job_id, service) + SELECT 6, label, status, platform_job_id, service + FROM processing_jobs WHERE id = 4''')) + with pytest.raises(ValueError, match='Ambiguous'): + get_gep_job(jobs_database, 'ns:job') + + +@pytest.mark.parametrize('current', [Status.FINISHED, Status.FAILED, Status.CANCELED]) +def test_terminal_state_cannot_regress(current): + job = ProcessingJobRecord(id=1, status=current, platform_job_id='ns:job') + with patch('app.services.job_status.update_job_status_by_id') as update: + with pytest.raises(InvalidJobTransition): + apply_job_status(None, job, Status.RUNNING) + update.assert_not_called() + assert job.status == current + + +def test_duplicate_status_is_noop(): + job = ProcessingJobRecord(id=1, status=Status.FINISHED) + with patch('app.services.job_status.update_job_status_by_id') as update: + apply_job_status(None, job, Status.FINISHED) + update.assert_not_called() + + +def test_routes_and_openapi(client): + paths = client.get('/openapi.json').json()['paths'] + for event in ('success', 'inProgress', 'failed'): + assert CALLBACK_PREFIX + '/' + event in paths + assert '/unit_jobs/' + event not in paths + response = client.post('/unit_jobs/' + event, json={}) + assert response.status_code in (404, 405) diff --git a/tests/routers/test_unit_jobs_callbacks.py b/tests/platforms/integrations/gep/test_router.py similarity index 86% rename from tests/routers/test_unit_jobs_callbacks.py rename to tests/platforms/integrations/gep/test_router.py index d247566..2fbfbe9 100644 --- a/tests/routers/test_unit_jobs_callbacks.py +++ b/tests/platforms/integrations/gep/test_router.py @@ -101,16 +101,17 @@ @pytest.fixture def callback_dependencies(fake_processing_job_record): + fake_processing_job_record.platform_job_id = "default:job-123" with ( patch( - "app.routers.unit_jobs.get_job_by_platform_or_id", + "app.platforms.implementations.gep.router.get_gep_job", return_value=fake_processing_job_record, ) as lookup, patch( - "app.routers.unit_jobs.ProcessingStateMachine.can_transition", + "app.services.job_status.ProcessingStateMachine.can_transition", return_value=True, ) as can_transition, - patch("app.routers.unit_jobs.update_job_status_by_id") as update, + patch("app.services.job_status.update_job_status_by_id") as update, ): yield lookup, can_transition, update @@ -135,7 +136,7 @@ def test_callback_updates_job_status( initial_status = fake_processing_job_record.status response = client.post( - f"/unit_jobs/{endpoint}", + f"/integrations/ogc-api-processes/gep/callbacks/{endpoint}", json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) @@ -159,7 +160,7 @@ def test_callback_rejects_invalid_transition( can_transition.return_value = False response = client.post( - f"/unit_jobs/{endpoint}", + f"/integrations/ogc-api-processes/gep/callbacks/{endpoint}", json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) @@ -168,7 +169,7 @@ def test_callback_rejects_invalid_transition( assert response.headers["content-type"] == "application/problem+json" data = response.json() assert data["status"] == 422 - assert data["instance"].endswith(f"/unit_jobs/{endpoint}") + assert data["instance"].endswith(f"/integrations/ogc-api-processes/gep/callbacks/{endpoint}") assert len(data["errors"]) == 1 assert data["errors"][0]["pointer"] == "subject" assert "default:job-123" in data["errors"][0]["detail"] @@ -189,7 +190,7 @@ def test_callback_missing_job_404_problem_details( lookup.return_value = None response = client.post( - f"/unit_jobs/{endpoint}", + f"/integrations/ogc-api-processes/gep/callbacks/{endpoint}", json={**payload, "subject": "default:test-uid:nonexistent"}, headers={"Content-Type": "application/cloudevents+json"}, ) @@ -199,7 +200,7 @@ def test_callback_missing_job_404_problem_details( data = response.json() assert data["status"] == 404 assert data["title"] == "Not Found" - assert data["instance"].endswith(f"/unit_jobs/{endpoint}") + assert data["instance"].endswith(f"/integrations/ogc-api-processes/gep/callbacks/{endpoint}") assert data["errors"] == [ { "detail": "Job default:nonexistent does not exist in the Job registry", @@ -221,7 +222,7 @@ def test_callback_in_progress_uncommitted_job_acknowledges_cleanly( lookup.return_value = None response = client.post( - "/unit_jobs/inProgress", + "/integrations/ogc-api-processes/gep/callbacks/inProgress", json=payload, headers={"Content-Type": "application/cloudevents+json"}, ) @@ -242,7 +243,7 @@ def test_callback_unexpected_error_returns_500( dependencies[failure_point].side_effect = RuntimeError("callback failure") response = client.post( - "/unit_jobs/success", + "/integrations/ogc-api-processes/gep/callbacks/success", json=PIPED_PAYLOAD, headers={"Content-Type": "application/cloudevents+json"}, ) @@ -251,7 +252,7 @@ def test_callback_unexpected_error_returns_500( assert response.headers["content-type"] == "application/problem+json" data = response.json() assert data["status"] == 500 - assert data["instance"].endswith("/unit_jobs/success") + assert data["instance"].endswith("/integrations/ogc-api-processes/gep/callbacks/success") assert data["errors"] == [{ "parameter": "job_id", "detail": ( @@ -266,24 +267,24 @@ def test_callback_unexpected_error_returns_500( can_transition.assert_not_called() -def test_extract_job_identifier_from_cloudevent(): - from app.routers.unit_jobs import _extract_job_identifier_from_cloudevent +def testextract_job_identifier(): + from app.platforms.implementations.gep.callbacks import extract_job_identifier # 3-part subject (::) event = MagicMock(subject="myns:uid-1234:wf-5678") - assert _extract_job_identifier_from_cloudevent(event) == "myns:wf-5678" + assert extract_job_identifier(event) == "myns:wf-5678" # 2-part subject event = MagicMock(subject="myns:wf-5678") - assert _extract_job_identifier_from_cloudevent(event) == "myns:wf-5678" + assert extract_job_identifier(event) == "myns:wf-5678" # Single string event = MagicMock(subject="wf-5678") - assert _extract_job_identifier_from_cloudevent(event) == "wf-5678" + assert extract_job_identifier(event) == "wf-5678" # Missing / None subject event = MagicMock(subject=None) - assert _extract_job_identifier_from_cloudevent(event) is None + assert extract_job_identifier(event) is None # --- Outbound Subscriber Payload Test --- @@ -334,6 +335,12 @@ async def test_ogc_platform_execute_job_injects_subscriber( execute_data = kwargs["execute"] assert "subscriber" in execute_data subscriber = execute_data["subscriber"] - assert subscriber["successUri"].endswith("/unit_jobs/success") - assert subscriber["inProgressUri"].endswith("/unit_jobs/inProgress") - assert subscriber["failedUri"].endswith("/unit_jobs/failed") + assert subscriber["successUri"].endswith( + "/integrations/ogc-api-processes/gep/callbacks/success" + ) + assert subscriber["inProgressUri"].endswith( + "/integrations/ogc-api-processes/gep/callbacks/inProgress" + ) + assert subscriber["failedUri"].endswith( + "/integrations/ogc-api-processes/gep/callbacks/failed" + ) diff --git a/tests/platforms/test_ogc_api_process_platform.py b/tests/platforms/test_ogc_api_process_platform.py index adfa0b4..e33c1eb 100644 --- a/tests/platforms/test_ogc_api_process_platform.py +++ b/tests/platforms/test_ogc_api_process_platform.py @@ -479,9 +479,15 @@ async def test_execute_job_returns_namespaced_job_id( "email": "alice@example.com", }, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/success", - "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/failed", + "successUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/success" + ), + "inProgressUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/inProgress" + ), + "failedUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/failed" + ), }, }, _headers={ @@ -536,9 +542,15 @@ async def test_execute_job_returns_plain_job_id_without_namespace( "user_id": "user-123", }, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/success", - "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/failed", + "successUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/success" + ), + "inProgressUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/inProgress" + ), + "failedUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/failed" + ), }, }, _headers={ @@ -587,9 +599,15 @@ async def test_execute_job_omits_missing_optional_user_fields( "inputs": {"limit": 10}, "properties": {"title": "My job", "application": "buffer"}, "subscriber": { - "successUri": "http://localhost:8000/unit_jobs/success", - "inProgressUri": "http://localhost:8000/unit_jobs/inProgress", - "failedUri": "http://localhost:8000/unit_jobs/failed", + "successUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/success" + ), + "inProgressUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/inProgress" + ), + "failedUri": ( + "http://localhost:8000/integrations/ogc-api-processes/gep/callbacks/failed" + ), }, }, _headers={ diff --git a/tests/services/test_processing.py b/tests/services/test_processing.py index 3d47566..641cbc6 100644 --- a/tests/services/test_processing.py +++ b/tests/services/test_processing.py @@ -657,28 +657,3 @@ async def test_delete_processing_job_noop_when_not_found( mock_get_job.assert_called_once_with(fake_db_session, 1, "foobar") mock_remove_job.assert_not_called() - - -def test_get_job_by_platform_or_id_numeric(fake_db_session, fake_processing_job_record): - from app.services.processing import get_job_by_platform_or_id - - with patch( - "app.services.processing.get_job_by_id", return_value=fake_processing_job_record - ) as mock_get_by_id: - result = get_job_by_platform_or_id(fake_db_session, "123") - assert result == fake_processing_job_record - mock_get_by_id.assert_called_once_with(fake_db_session, 123) - - -def test_get_job_by_platform_or_id_string(fake_db_session, fake_processing_job_record): - from app.services.processing import get_job_by_platform_or_id - - fake_query = MagicMock() - fake_db_session.query.return_value = fake_query - fake_query.filter.return_value = fake_query - fake_query.first.return_value = fake_processing_job_record - - # 3-part CloudEvent subject format - result = get_job_by_platform_or_id(fake_db_session, "myns:uid-1:wf-abc") - assert result == fake_processing_job_record - fake_db_session.query.assert_called_once() From 5efd12ac6e398ec1808ae58f2d382ef3413b408c Mon Sep 17 00:00:00 2001 From: maxmiraftab Date: Thu, 29 Oct 2026 12:26:57 +0100 Subject: [PATCH 14/14] feat(gep): authenticate callback service accounts Verify inbound GEP callback bearer tokens via JWKS and authorize callers against configured backends to prevent unauthorized status updates. --- app/platforms/implementations/gep/auth.py | 92 ++++++++++ app/platforms/implementations/gep/router.py | 33 +++- app/platforms/implementations/gep/schemas.py | 12 ++ app/platforms/implementations/gep/settings.py | 19 ++ openapi.json | 24 +++ .../integrations/gep/test_gep_auth.py | 169 ++++++++++++++++++ .../platforms/integrations/gep/test_router.py | 5 + 7 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 app/platforms/implementations/gep/auth.py create mode 100644 app/platforms/implementations/gep/settings.py create mode 100644 tests/platforms/integrations/gep/test_gep_auth.py diff --git a/app/platforms/implementations/gep/auth.py b/app/platforms/implementations/gep/auth.py new file mode 100644 index 0000000..7714187 --- /dev/null +++ b/app/platforms/implementations/gep/auth.py @@ -0,0 +1,92 @@ +"""Authenticate GEP callback service accounts independently of APEx user login.""" + +from functools import lru_cache +from typing import Annotated + +import jwt +from fastapi import Depends, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from http_apis_problems_detail import ErrorDetail +from http_apis_problems_detail.fastapi import ForbiddenException, UnauthorizedException +from jwt import PyJWKClient + +from app.platforms.implementations.gep.settings import get_callback_backends + +callback_bearer = HTTPBearer(auto_error=False, scheme_name="GepCallbackBearer") + + +@lru_cache(maxsize=32) +def _callback_jwks_client(issuer: str) -> PyJWKClient: + # Called only after matching a configured issuer, never an arbitrary token URL. + # Cache the JWKS rather than individual keys indefinitely, allowing key rotation. + return PyJWKClient( + f"{issuer.rstrip('/')}/protocol/openid-connect/certs", + cache_jwk_set=True, + lifespan=300, + timeout=10, + ) + + +def verify_callback_token( + request: Request, + creds: Annotated[HTTPAuthorizationCredentials | None, Depends(callback_bearer)], +) -> set[str]: + """Return the backend endpoints this verified service account may update.""" + unauthorized = UnauthorizedException( + request=request, + errors=[ErrorDetail( + header="Authorization", detail="Callback Bearer Authorization token not set or invalid." + )], + headers={"WWW-Authenticate": "Bearer"}, + ) + if creds is None or creds.scheme.lower() != "bearer": + raise unauthorized + + # Configuration errors are server errors, not invalid client credentials. + configured_backends = get_callback_backends() + try: + issuer = jwt.decode( + creds.credentials, options={"verify_signature": False} + ).get("iss") + candidates = { + endpoint: cfg for endpoint, cfg in configured_backends.items() + if isinstance(issuer, str) and issuer + and cfg.callback_issuer == issuer + and cfg.callback_audience and cfg.callback_clients + } + if not candidates: + raise unauthorized + + key = _callback_jwks_client(issuer).get_signing_key_from_jwt(creds.credentials).key + allowed: set[str] = set() + valid_audience = False + # Require an audience match; reject any other JWT validation failure. + for endpoint, cfg in candidates.items(): + try: + claims = jwt.decode( + creds.credentials, + key, + algorithms=["RS256"], + issuer=issuer, + audience=cfg.callback_audience, + options={"require": ["iss", "aud", "exp"]}, + ) + except jwt.InvalidAudienceError: + continue + valid_audience = True + client = claims.get("azp") + if isinstance(client, str) and client in cfg.callback_clients: + allowed.add(endpoint.rstrip("/")) + if not valid_audience: + raise unauthorized + except (jwt.PyJWTError, ValueError, TypeError, OverflowError) as exc: + raise unauthorized from exc + + if not allowed: + raise ForbiddenException( + request=request, + errors=[ErrorDetail( + header="Authorization", detail="Callback client is not allowed." + )], + ) + return allowed diff --git a/app/platforms/implementations/gep/router.py b/app/platforms/implementations/gep/router.py index 0336462..356dcc1 100644 --- a/app/platforms/implementations/gep/router.py +++ b/app/platforms/implementations/gep/router.py @@ -1,5 +1,8 @@ """Receive GEP stage CloudEvents, not arbitrary OGC callback payloads.""" +import json +from typing import Annotated + from fastapi import APIRouter, Depends, Request, status from loguru import logger from sqlalchemy.orm import Session @@ -7,9 +10,10 @@ from http_apis_problems_detail import ErrorDetail from http_apis_problems_detail.fastapi import ( PROBLEM_RESPONSES, BusinessRuleViolationException, ServerErrorException, - NotFoundException, ProblemRegistryException, + NotFoundException, ProblemRegistryException, ForbiddenException, ) +from app.platforms.implementations.gep.auth import verify_callback_token from app.database.db import get_db from app.schemas.enum import ProcessingStatusEnum from app.services.job_status import apply_job_status, InvalidJobTransition @@ -29,6 +33,7 @@ def _handle_callback_status_update( cloud_event: BaseCloudEvent, target_status: ProcessingStatusEnum, request: Request, + caller: set[str], ) -> None: """Internal helper to update job status from callback CloudEvents.""" job_identifier = extract_job_identifier(cloud_event) or "" @@ -58,6 +63,17 @@ def _handle_callback_status_update( ] ) + # Authorize against persisted dispatch metadata, not CloudEvent claims. + endpoint = json.loads(job.service)["endpoint"].rstrip("/") + if endpoint not in caller: + raise ForbiddenException( + request=request, + errors=[ErrorDetail( + pointer="subject", + detail="Callback caller is not authorized to update this job.", + )], + ) + try: apply_job_status(db, job, target_status) except InvalidJobTransition as exc: @@ -98,6 +114,8 @@ def _handle_callback_status_update( operation_id="onGepJobSuccess", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_401_UNAUTHORIZED: PROBLEM_RESPONSES[status.HTTP_401_UNAUTHORIZED], + status.HTTP_403_FORBIDDEN: PROBLEM_RESPONSES[status.HTTP_403_FORBIDDEN], status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], status.HTTP_422_UNPROCESSABLE_CONTENT: ( @@ -111,11 +129,12 @@ def _handle_callback_status_update( def on_gep_job_success( request: Request, body: SuccessEvent, + caller: Annotated[set[str], Depends(verify_callback_token)], db: Session = Depends(get_db), ) -> None: """URIs for success callbacks for jobs.""" _handle_callback_status_update( - db, body, ProcessingStatusEnum.FINISHED, request + db, body, ProcessingStatusEnum.FINISHED, request, caller ) @@ -131,6 +150,8 @@ def on_gep_job_success( operation_id="onGepJobProgress", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_401_UNAUTHORIZED: PROBLEM_RESPONSES[status.HTTP_401_UNAUTHORIZED], + status.HTTP_403_FORBIDDEN: PROBLEM_RESPONSES[status.HTTP_403_FORBIDDEN], status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], status.HTTP_422_UNPROCESSABLE_CONTENT: ( @@ -144,11 +165,12 @@ def on_gep_job_success( def on_gep_job_progress( request: Request, body: ProgressEvent, + caller: Annotated[set[str], Depends(verify_callback_token)], db: Session = Depends(get_db), ) -> None: """URIs for progress callbacks for jobs.""" target_status = STAGE_TO_APEX_STATUS[body.type] - _handle_callback_status_update(db, body, target_status, request) + _handle_callback_status_update(db, body, target_status, request, caller) @router.post( @@ -163,6 +185,8 @@ def on_gep_job_progress( operation_id="onGepJobFailed", responses={ status.HTTP_200_OK: {"description": "Job status updated successfully."}, + status.HTTP_401_UNAUTHORIZED: PROBLEM_RESPONSES[status.HTTP_401_UNAUTHORIZED], + status.HTTP_403_FORBIDDEN: PROBLEM_RESPONSES[status.HTTP_403_FORBIDDEN], status.HTTP_400_BAD_REQUEST: PROBLEM_RESPONSES[status.HTTP_400_BAD_REQUEST], status.HTTP_404_NOT_FOUND: PROBLEM_RESPONSES[status.HTTP_404_NOT_FOUND], status.HTTP_422_UNPROCESSABLE_CONTENT: ( @@ -176,9 +200,10 @@ def on_gep_job_progress( def on_gep_job_failed( request: Request, body: FailureEvent, + caller: Annotated[set[str], Depends(verify_callback_token)], db: Session = Depends(get_db), ) -> None: """URIs for failed callbacks for jobs.""" _handle_callback_status_update( - db, body, ProcessingStatusEnum.FAILED, request + db, body, ProcessingStatusEnum.FAILED, request, caller ) diff --git a/app/platforms/implementations/gep/schemas.py b/app/platforms/implementations/gep/schemas.py index 456aa37..b4d84cb 100644 --- a/app/platforms/implementations/gep/schemas.py +++ b/app/platforms/implementations/gep/schemas.py @@ -1,5 +1,9 @@ """Payload models accepted by the GEP callback integration.""" +from pydantic import Field + +from app.config.schemas import BackendAuthConfig + from stage_events_client.models import ( CompletedCloudEvent, DismissedCloudEvent, FailedCloudEvent, PipedCloudEvent, PreparedCloudEvent, StagedCloudEvent, SubmittedCloudEvent, @@ -10,3 +14,11 @@ ) SuccessEvent = PipedCloudEvent | CompletedCloudEvent FailureEvent = FailedCloudEvent + + +class CallbackBackendAuthConfig(BackendAuthConfig): + """Backend authentication extended locally for inbound GEP callbacks.""" + + callback_issuer: str | None = None + callback_audience: str | None = None + callback_clients: list[str] = Field(default_factory=list) diff --git a/app/platforms/implementations/gep/settings.py b/app/platforms/implementations/gep/settings.py new file mode 100644 index 0000000..1bf2ee1 --- /dev/null +++ b/app/platforms/implementations/gep/settings.py @@ -0,0 +1,19 @@ +"""Read GEP callback settings without changing shared backend configuration.""" + +from functools import lru_cache + +from pydantic import TypeAdapter + +from app.config.settings import settings +from app.platforms.implementations.gep.schemas import CallbackBackendAuthConfig + + +@lru_cache(maxsize=1) +def _parse_callback_backends(raw: str) -> dict[str, CallbackBackendAuthConfig]: + return TypeAdapter(dict[str, CallbackBackendAuthConfig]).validate_json(raw) + + +def get_callback_backends() -> dict[str, CallbackBackendAuthConfig]: + # The shared BackendAuthConfig ignores callback fields. Reparse the same raw + # BACKENDS value with our local extension, preserving the deployment contract. + return _parse_callback_backends(settings.backends or "{}") diff --git a/openapi.json b/openapi.json index 329d43f..e3b0580 100644 --- a/openapi.json +++ b/openapi.json @@ -728,6 +728,12 @@ "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -756,6 +762,12 @@ "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -784,6 +796,12 @@ "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -2706,6 +2724,12 @@ "BusinessRuleViolation": { "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/BusinessRuleViolation" }, + "Unauthorized": { + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/Unauthorized" + }, + "Forbidden": { + "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/Forbidden" + }, "NotFound": { "$ref": "https://terradue.github.io/http-apis-problems-detail/openapi.yaml#/components/responses/NotFound" }, diff --git a/tests/platforms/integrations/gep/test_gep_auth.py b/tests/platforms/integrations/gep/test_gep_auth.py new file mode 100644 index 0000000..fbfb013 --- /dev/null +++ b/tests/platforms/integrations/gep/test_gep_auth.py @@ -0,0 +1,169 @@ +"""Offline security tests; all GEP feature changes remain inside this package.""" +import json +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI +from fastapi.testclient import TestClient +from http_apis_problems_detail.fastapi import ( + ProblemRegistryException, problem_registry_exception_handler, +) + +from app.config.settings import settings +from app.platforms.implementations.gep import auth +from app.platforms.implementations.gep import router as routes +from app.platforms.implementations.gep.settings import get_callback_backends +from app.platforms.implementations.gep.subscriber import CALLBACK_PREFIX +from app.schemas.enum import ProcessingStatusEnum + +ISSUER = "https://iam.example.test/realms/callbacks" +ENDPOINT = "https://mate.example.test" + + +@pytest.fixture +def harness(monkeypatch): + backend = dict(token_provider="terradue", callback_issuer=ISSUER, + callback_audience="apex-dispatch-api", + callback_clients=["mate-api-callbacks"]) + monkeypatch.setattr(settings, "backends", json.dumps({ENDPOINT: backend})) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwks = MagicMock() + jwks.get_signing_key_from_jwt.return_value.key = key.public_key() + factory = MagicMock(return_value=jwks) + monkeypatch.setattr(auth, "_callback_jwks_client", factory) + job = SimpleNamespace(id=1, status=ProcessingStatusEnum.RUNNING, + service=json.dumps({"endpoint": ENDPOINT})) + lookup = MagicMock(return_value=job) + update = MagicMock() + monkeypatch.setattr(routes, "get_gep_job", lookup) + monkeypatch.setattr(routes, "apply_job_status", update) + api = FastAPI() + api.include_router(routes.router) + api.add_exception_handler(ProblemRegistryException, problem_registry_exception_handler) + api.dependency_overrides[routes.get_db] = lambda: MagicMock() + + def token(**changes): + claims = dict(iss=ISSUER, aud="apex-dispatch-api", azp="mate-api-callbacks", + exp=int(time.time()) + 300) + claims.update(changes) + return jwt.encode({k: v for k, v in claims.items() if v is not None}, + key, algorithm="RS256", headers={"kid": "test"}) + + with TestClient(api) as client: + yield SimpleNamespace(client=client, token=token, lookup=lookup, update=update, + job=job, factory=factory, backend=backend, api=api) + + +def post(h, endpoint="success", token=None): + event_type = {"success": "piped", "inProgress": "staged", "failed": "failed"}[endpoint] + payload = dict(source="terradue:apex:test", subject="default:uid:job-123", + partitionkey="part-1", type=event_type, + data=dict(namespace="default", process_id="test-proc", job_id="job-123", + entries={}, outputs={}, usage={})) + return h.client.post(CALLBACK_PREFIX + "/" + endpoint, json=payload, + headers={} if token is None else {"Authorization": token}) + + +@pytest.mark.parametrize("endpoint", ["success", "inProgress", "failed"]) +@pytest.mark.parametrize("scenario,expected", [ + ("valid", 200), ("missing", 401), ("basic", 401), ("malformed", 401), + ("expired", 401), ("issuer", 401), ("audience", 401), ("no_exp", 401), + ("no_aud", 401), ("no_iss", 401), ("future_nbf", 401), ("future_iat", 401), + ("client", 403), ("no_azp", 403), ("other_job", 403), ("slash", 200), + ("bad_signature", 401), ("jwks_failure", 401), ("algorithm", 401), +]) +def test_callback_security(harness, endpoint, scenario, expected): + h = harness + changes = {"expired": {"exp": 1}, "issuer": {"iss": "https://unknown.test"}, + "audience": {"aud": "other"}, "no_exp": {"exp": None}, + "no_aud": {"aud": None}, "no_iss": {"iss": None}, + "future_nbf": {"nbf": 9999999999}, "future_iat": {"iat": 9999999999}, + "client": {"azp": "other"}, "no_azp": {"azp": None}} + token = "Bearer " + h.token(**changes.get(scenario, {})) + if scenario == "missing": + token = None + elif scenario == "basic": + token = "Basic abc" + elif scenario == "malformed": + token = "Bearer invalid" + elif scenario in ("other_job", "slash"): + target = "https://other.test" if scenario == "other_job" else ENDPOINT + "/" + h.job.service = json.dumps({"endpoint": target}) + elif scenario == "bad_signature": + h.factory.return_value.get_signing_key_from_jwt.return_value.key = rsa.generate_private_key( + public_exponent=65537, key_size=2048).public_key() + elif scenario == "jwks_failure": + h.factory.return_value.get_signing_key_from_jwt.side_effect = ( + jwt.PyJWKClientConnectionError("offline") + ) + elif scenario == "algorithm": + token = "Bearer " + jwt.encode({"iss": ISSUER}, "x" * 32, algorithm="HS256") + response = post(h, endpoint, token) + assert response.status_code == expected, response.text + if expected == 200: + h.update.assert_called_once() + else: + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json()["status"] == expected + assert response.json()["instance"].endswith(CALLBACK_PREFIX + "/" + endpoint) + h.update.assert_not_called() + if expected == 401: + assert response.headers["www-authenticate"] == "Bearer" + h.lookup.assert_not_called() + if scenario in ("issuer", "no_iss"): + h.factory.assert_not_called() + + +@pytest.mark.parametrize("other_policy", ["client", "audience", "disabled"]) +def test_shared_issuer_isolation(harness, monkeypatch, other_policy): + h = harness + other = dict(h.backend) + other.update({"client": {"callback_clients": ["other"]}, + "audience": {"callback_audience": "other"}, + "disabled": {"callback_clients": []}}[other_policy]) + backends_config = {ENDPOINT: h.backend, "https://other.test": other} + monkeypatch.setattr(settings, "backends", json.dumps(backends_config)) + h.job.service = json.dumps({"endpoint": "https://other.test"}) + assert post(h, token="Bearer " + h.token()).status_code == 403 + h.update.assert_not_called() + + +def test_raw_backends_retains_local_fields(harness): + # Shared config deliberately remains unchanged and drops these extra fields. + from app.config.schemas import BackendAuthConfig + assert not hasattr(BackendAuthConfig(**harness.backend), "callback_issuer") + assert get_callback_backends()[ENDPOINT].callback_issuer == ISSUER + + +@pytest.mark.parametrize("raw", ["", "{}"]) +def test_no_config_fails_closed(harness, monkeypatch, raw): + monkeypatch.setattr(settings, "backends", raw) + assert post(harness, token="Bearer " + harness.token()).status_code == 401 + harness.factory.assert_not_called() + + +@pytest.mark.parametrize( + "endpoint,expected", [("success", 404), ("failed", 404), ("inProgress", 200)] +) +def test_unknown_job(harness, endpoint, expected): + harness.lookup.return_value = None + assert post(harness, endpoint, "Bearer " + harness.token()).status_code == expected + harness.update.assert_not_called() + + +def test_transition_failure(harness): + harness.update.side_effect = routes.InvalidJobTransition("Invalid transition") + assert post(harness, token="Bearer " + harness.token()).status_code == 422 + + +def test_openapi(harness): + schema = harness.api.openapi() + for endpoint in ("success", "inProgress", "failed"): + operation = schema["paths"][CALLBACK_PREFIX + "/" + endpoint]["post"] + assert {"GepCallbackBearer": []} in operation["security"] + for code in ("401", "403"): + assert "application/problem+json" in operation["responses"][code]["content"] diff --git a/tests/platforms/integrations/gep/test_router.py b/tests/platforms/integrations/gep/test_router.py index 2fbfbe9..d8d201c 100644 --- a/tests/platforms/integrations/gep/test_router.py +++ b/tests/platforms/integrations/gep/test_router.py @@ -2,6 +2,8 @@ import pytest from fastapi import status +from app.main import app +from app.platforms.implementations.gep.auth import verify_callback_token from app.schemas.enum import OutputFormatEnum, ProcessingStatusEnum from app.schemas.unit_job import ServiceDetails from app.platforms.implementations.ogc_api_process import OGCAPIProcessPlatform @@ -102,6 +104,8 @@ @pytest.fixture def callback_dependencies(fake_processing_job_record): fake_processing_job_record.platform_job_id = "default:job-123" + # Ensure authorized caller matches the endpoint in fake_processing_job_record.service ("foo") + app.dependency_overrides[verify_callback_token] = lambda: {"foo"} with ( patch( "app.platforms.implementations.gep.router.get_gep_job", @@ -114,6 +118,7 @@ def callback_dependencies(fake_processing_job_record): patch("app.services.job_status.update_job_status_by_id") as update, ): yield lookup, can_transition, update + app.dependency_overrides.pop(verify_callback_token, None) CALLBACK_CASES = [