Files
govoplan-workflow-engine/src/govoplan_workflow_engine/backend/router.py
T

1506 lines
47 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
ReferenceOptionListResponse,
ReferenceOptionResponse,
)
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
validate_access_scope_reference,
)
from govoplan_core.db.session import get_session
from govoplan_workflow_engine.backend.governance import (
definition_decision,
normalize_definition_scope,
require_definition_action,
)
from govoplan_workflow_engine.backend.contributions import (
compare_workflow_override_to_standard,
reconcile_workflow_definition_contributions,
reset_workflow_override_to_standard,
)
from govoplan_workflow_engine.backend.manifest import (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
INSTANCE_READ_SCOPE,
INSTANCE_START_SCOPE,
INSTANCE_TRANSITION_SCOPE,
)
from govoplan_workflow_engine.backend.node_library import (
BPMN_NODE_TYPES,
WORKFLOW_GRAPH_LIBRARY,
)
from govoplan_workflow_engine.backend.bpmn import (
BPMN_MODEL_NAMESPACE,
BpmnDiagnostic,
BpmnInspectionError,
NATIVE_EXECUTION_ELEMENTS,
NATIVE_MAPPING_ELEMENTS,
inspect_bpmn_xml,
)
from govoplan_workflow_engine.backend.bpmn_adapters import (
BpmnAdapterError,
bpmn_adapter_registry,
compile_bpmn_to_graph,
)
from govoplan_workflow_engine.backend.bpmn_graph import (
BpmnGraphError,
NATIVE_BPMN_ADAPTER_ID,
NATIVE_BPMN_ADAPTER_VERSION,
canonical_bpmn_graph,
export_bpmn_graph,
)
from govoplan_workflow_engine.backend.schemas import (
BpmnAdapterProfileResponse,
BpmnCompileRequest,
BpmnCompileResponse,
BpmnDiagnosticResponse,
BpmnElementSupportResponse,
BpmnInspectionRequest,
BpmnInspectionResponse,
BpmnRevisionDocumentResponse,
BpmnRenderRequest,
BpmnRenderResponse,
BpmnSupportProfileResponse,
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeleteResponse,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionListResponse,
WorkflowDefinitionResponse,
WorkflowDefinitionRevisionListResponse,
WorkflowDefinitionRevisionResponse,
WorkflowDefinitionUpdateRequest,
WorkflowDiagnosticResponse,
WorkflowGraphValidationRequest,
WorkflowGraphValidationResponse,
WorkflowInstanceListResponse,
WorkflowInstanceResponse,
WorkflowInstanceStartRequest,
WorkflowNodeLibraryResponse,
WorkflowNodeTypeResponse,
WorkflowPortResponse,
WorkflowStandardDiffResponse,
WorkflowStepActionRequest,
)
from govoplan_workflow_engine.backend.instance_service import (
cancel_instance,
get_instance,
instance_response,
list_instances,
reconcile_instance,
resolve_step,
start_instance,
)
from govoplan_workflow_engine.backend.runtime import get_registry
from govoplan_workflow_engine.backend.service import (
WorkflowBpmnValidationError,
WorkflowConflictError,
WorkflowError,
WorkflowNotFoundError,
WorkflowValidationError,
activate_definition,
archive_definition,
create_definition,
definition_response,
delete_definition,
derive_definition,
get_definition,
get_definition_revision,
list_definition_revisions,
list_definitions,
revision_response,
revision_bpmn_summary,
update_definition,
)
from govoplan_workflow_engine.backend.validation import validate_workflow_graph
router = APIRouter(prefix="/workflow", tags=["workflow"])
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing one of the required scopes: {', '.join(scopes)}",
)
def _http_error(exc: WorkflowError) -> HTTPException:
if isinstance(exc, WorkflowNotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, WorkflowConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, WorkflowBpmnValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in exc.diagnostics
],
},
)
if isinstance(exc, WorkflowValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"node_id": item.node_id,
"field": item.field,
}
for item in exc.diagnostics
],
},
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _governance_http_error(
exc: PermissionError | ValueError,
) -> HTTPException:
return HTTPException(
status_code=(
status.HTTP_403_FORBIDDEN
if isinstance(exc, PermissionError)
else status.HTTP_422_UNPROCESSABLE_CONTENT
),
detail=str(exc),
)
def _definition_response(
session: Session,
definition,
principal: ApiPrincipal,
*,
revision: int | None = None,
) -> WorkflowDefinitionResponse:
return definition_response(
session,
definition,
principal=principal,
registry=get_registry(),
revision=revision,
)
def _actor_id(principal: ApiPrincipal) -> str | None:
return principal.account_id or principal.membership_id or principal.identity_id
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
definition_id: str,
details: dict[str, object],
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type="workflow_definition",
object_id=definition_id,
details=details,
)
def _audit_instance(
session: Session,
principal: ApiPrincipal,
*,
action: str,
instance_id: str,
details: dict[str, object],
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type="workflow_instance",
object_id=instance_id,
details=details,
)
def _require_instance_view(instance, principal: ApiPrincipal) -> None:
require_definition_action(
instance.definition,
principal=principal,
registry=get_registry(),
action="view",
)
def _adapter_profile_response(profile) -> BpmnAdapterProfileResponse:
return BpmnAdapterProfileResponse(
id=profile.id,
version=profile.version,
label=profile.label,
description=profile.description,
conformance=profile.conformance,
runtime_kind=profile.runtime_kind,
executable=profile.executable,
supported_elements=list(profile.supported_elements),
supported_event_definitions=list(profile.supported_event_definitions),
requirements=list(profile.requirements),
)
def _bpmn_inspection_response(
xml: str,
*,
adapter_id: str,
adapter_version: str | None = None,
activation: bool,
) -> BpmnInspectionResponse:
result = inspect_bpmn_xml(xml)
adapter = bpmn_adapter_registry().resolve(adapter_id, adapter_version)
adapter_diagnostics = (
adapter.diagnostics(xml, result, activation=activation)
if adapter is not None
else ()
)
diagnostics = list(result.diagnostics)
diagnostics.extend(adapter_diagnostics)
if adapter is None:
suffix = f"@{adapter_version}" if adapter_version else ""
diagnostics.append(
BpmnDiagnostic(
severity="error",
code="adapter.unavailable",
message=f"BPMN execution adapter {adapter_id}{suffix} is not installed.",
)
)
deduplicated = {
(item.code, item.element_id, item.message): item
for item in diagnostics
}.values()
diagnostic_items = list(deduplicated)
return BpmnInspectionResponse(
valid_xml=result.valid_xml,
definitions_id=result.definitions_id,
target_namespace=result.target_namespace,
process_count=result.process_count,
executable_process_count=result.executable_process_count,
collaboration_count=result.collaboration_count,
choreography_count=result.choreography_count,
element_counts=result.element_counts,
support_counts=result.support_counts,
elements=[
BpmnElementSupportResponse(
element_type=item.element_type,
element_id=item.element_id,
name=item.name,
parent_type=item.parent_type,
parent_id=item.parent_id,
support_level=item.support_level,
)
for item in result.elements
],
diagnostics=[
BpmnDiagnosticResponse(
severity=item.severity,
code=item.code,
message=item.message,
element_id=item.element_id,
)
for item in diagnostic_items
],
adapter_id=adapter.profile.id if adapter else adapter_id,
adapter_version=(
adapter.profile.version if adapter else adapter_version
),
runtime_kind=adapter.profile.runtime_kind if adapter else None,
executable=bool(adapter and adapter.profile.executable),
activatable=bool(
activation
and adapter
and adapter.profile.executable
and not any(item.severity == "error" for item in diagnostic_items)
),
)
@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
def api_bpmn_support_profile(
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnSupportProfileResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return BpmnSupportProfileResponse(
specification="BPMN 2.0.2",
model_namespace=BPMN_MODEL_NAMESPACE,
interchange=(
"BPMN 2.0 is the native Workflow graph language. XML import and "
"export preserve standard notation and diagram geometry without "
"a browser-side BPMN modeler."
),
native_runtime=(
"Activation is fail-closed and requires a pinned adapter whose "
"declared conformance profile covers the complete document."
),
native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
native_mapping_elements=sorted(
NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
),
adapters=[
_adapter_profile_response(profile)
for profile in bpmn_adapter_registry().profiles()
],
)
@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
def api_inspect_bpmn(
payload: BpmnInspectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnInspectionResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
return _bpmn_inspection_response(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
activation=payload.activation,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/bpmn/compile", response_model=BpmnCompileResponse)
def api_compile_bpmn(
payload: BpmnCompileRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnCompileResponse:
_require_any_scope(
principal,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
adapter, _inspection, graph = compile_bpmn_to_graph(
payload.xml,
adapter_id=payload.adapter_id,
adapter_version=payload.adapter_version,
)
if graph is None:
raise BpmnAdapterError(
(
BpmnDiagnostic(
severity="error",
code="adapter.no_runtime_materialization",
message="The selected adapter does not compile to a native graph.",
),
)
)
inspection_response = _bpmn_inspection_response(
payload.xml,
adapter_id=adapter.profile.id,
adapter_version=adapter.profile.version,
activation=False,
)
except (BpmnAdapterError, BpmnInspectionError) as exc:
diagnostics = getattr(exc, "diagnostics", ())
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": str(exc),
"diagnostics": [
{
"severity": item.severity,
"code": item.code,
"message": item.message,
"element_id": item.element_id,
}
for item in diagnostics
],
},
) from exc
return BpmnCompileResponse(
adapter=_adapter_profile_response(adapter.profile),
graph=graph,
inspection=inspection_response,
)
@router.post("/bpmn/render", response_model=BpmnRenderResponse)
def api_render_bpmn(
payload: BpmnRenderRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRenderResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
try:
xml = export_bpmn_graph(
canonical_bpmn_graph(payload.graph),
name=payload.name,
)
inspection = _bpmn_inspection_response(
xml,
adapter_id=NATIVE_BPMN_ADAPTER_ID,
adapter_version=NATIVE_BPMN_ADAPTER_VERSION,
activation=False,
)
except (BpmnGraphError, BpmnInspectionError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRenderResponse(xml=xml, inspection=inspection)
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowNodeLibraryResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
return WorkflowNodeLibraryResponse(
id=WORKFLOW_GRAPH_LIBRARY.id,
version=WORKFLOW_GRAPH_LIBRARY.version,
allows_cycles=WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles,
nodes=[
WorkflowNodeTypeResponse(
type=definition.type,
category=definition.category,
category_label=WORKFLOW_GRAPH_LIBRARY.category_labels[definition.category],
label=definition.label,
description=definition.description,
icon=definition.icon,
input_ports=[
WorkflowPortResponse(
id=port.id,
label=port.label,
required=port.required,
multiple=port.multiple,
minimum_connections=port.minimum_connections,
)
for port in definition.input_ports
],
output_ports=[
WorkflowPortResponse(
id=port.id,
label=port.label,
required=port.required,
multiple=port.multiple,
minimum_connections=port.minimum_connections,
)
for port in definition.output_ports
],
config_fields=[
WorkflowConfigFieldResponse(
id=field.id,
label=field.label,
kind=field.kind,
required=field.required,
description=field.description,
options=list(field.options),
)
for field in definition.config_fields
],
default_config=dict(definition.default_config),
metadata=dict(definition.metadata),
)
for definition in BPMN_NODE_TYPES
],
)
@router.get("/scope-targets", response_model=ReferenceOptionListResponse)
def api_scope_targets(
scope_type: str,
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
registry = get_registry()
try:
page = access_scope_reference_page(
registry,
principal,
scope_type=scope_type,
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=has_scope(principal, ADMIN_SCOPE),
session=session,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options],
provider_available=access_scope_reference_provider_available(registry),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.post(
"/definitions/validate",
response_model=WorkflowGraphValidationResponse,
)
def api_validate_definition(
payload: WorkflowGraphValidationRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowGraphValidationResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ADMIN_SCOPE,
)
diagnostics = validate_workflow_graph(payload.graph)
return WorkflowGraphValidationResponse(
valid=not any(item.severity == "error" for item in diagnostics),
diagnostics=[
WorkflowDiagnosticResponse(
severity=item.severity,
code=item.code,
message=item.message,
node_id=item.node_id,
field=item.field,
)
for item in diagnostics
],
)
@router.get("/definitions", response_model=WorkflowDefinitionListResponse)
def api_list_definitions(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
registry = get_registry()
definitions = [
definition
for definition in list_definitions(
session,
tenant_id=principal.tenant_id,
)
if definition_decision(
definition,
principal=principal,
registry=registry,
action="view",
).allowed
]
return WorkflowDefinitionListResponse(
definitions=[
definition_response(
session,
definition,
principal=principal,
registry=registry,
)
for definition in definitions
]
)
@router.get("/instances", response_model=WorkflowInstanceListResponse)
def api_list_instances(
definition_id: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceListResponse:
_require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE)
try:
instances = [
instance
for instance in list_instances(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
limit=limit,
)
if definition_decision(
instance.definition,
principal=principal,
registry=get_registry(),
action="view",
).allowed
]
return WorkflowInstanceListResponse(
instances=[
instance_response(session, instance)
for instance in instances
]
)
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.post("/standards/reconcile", response_model=dict[str, object])
def api_reconcile_standards(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any_scope(principal, ADMIN_SCOPE)
result = reconcile_workflow_definition_contributions(
session,
registry=get_registry(),
tenant_ids=(principal.tenant_id,),
)
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="workflow.standards.reconciled",
object_type="workflow_standard_catalogue",
object_id="module-contributions",
details={
key: value
for key, value in result.items()
if key != "items"
},
)
session.commit()
return result
@router.post(
"/definitions/{definition_id}/instances",
response_model=WorkflowInstanceResponse,
status_code=status.HTTP_201_CREATED,
)
def api_start_instance(
definition_id: str,
payload: WorkflowInstanceStartRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceResponse:
_require_any_scope(principal, INSTANCE_START_SCOPE, ADMIN_SCOPE)
try:
instance, replayed = start_instance(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
principal=principal,
registry=get_registry(),
payload=payload,
start_origin=(
"user" if principal.auth_method == "session" else "api"
),
)
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit_instance(
session,
principal,
action=(
"workflow.instance.replayed"
if replayed
else "workflow.instance.started"
),
instance_id=instance.id,
details={
"definition_id": instance.definition_id,
"definition_revision_id": instance.definition_revision_id,
"idempotency_key": instance.idempotency_key,
"start_origin": instance.start_origin,
},
)
response = instance_response(session, instance, replayed=replayed)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/reset-standard",
response_model=WorkflowDefinitionResponse,
)
def api_reset_definition_to_standard(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
override = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
override,
principal=principal,
registry=get_registry(),
action="edit",
)
baseline = reset_workflow_override_to_standard(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.reset_to_standard",
definition_id=definition_id,
details={
"baseline_definition_id": baseline.id,
"baseline_revision": baseline.current_revision,
"baseline_hash": baseline.standard_contribution_hash,
},
)
response = _definition_response(session, baseline, principal)
session.commit()
return response
@router.get(
"/definitions/{definition_id}/standard-diff",
response_model=WorkflowStandardDiffResponse,
)
def api_compare_definition_to_standard(
definition_id: str,
include_unchanged: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowStandardDiffResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
return compare_workflow_override_to_standard(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
include_unchanged=include_unchanged,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.get(
"/instances/{instance_id}",
response_model=WorkflowInstanceResponse,
)
def api_get_instance(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceResponse:
_require_any_scope(principal, INSTANCE_READ_SCOPE, ADMIN_SCOPE)
try:
instance = get_instance(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
)
_require_instance_view(instance, principal)
return instance_response(session, instance)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.post(
"/instances/{instance_id}/reconcile",
response_model=WorkflowInstanceResponse,
)
def api_reconcile_instance(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceResponse:
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
try:
instance = get_instance(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
for_update=True,
)
_require_instance_view(instance, principal)
changed = reconcile_instance(
session,
instance=instance,
principal=principal,
registry=get_registry(),
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
if changed:
_audit_instance(
session,
principal,
action="workflow.instance.reconciled",
instance_id=instance.id,
details={
"status": instance.status,
"current_step_id": instance.current_step_id,
},
)
response = instance_response(session, instance)
session.commit()
return response
@router.post(
"/instances/{instance_id}/steps/{step_id}/actions",
response_model=WorkflowInstanceResponse,
)
def api_resolve_instance_step(
instance_id: str,
step_id: str,
payload: WorkflowStepActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceResponse:
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
try:
existing = get_instance(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
)
_require_instance_view(existing, principal)
instance = resolve_step(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
step_id=step_id,
actor_id=_actor_id(principal),
principal=principal,
registry=get_registry(),
payload=payload,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit_instance(
session,
principal,
action=f"workflow.instance.{payload.action}",
instance_id=instance.id,
details={
"step_id": step_id,
"status": instance.status,
},
)
response = instance_response(session, instance)
session.commit()
return response
@router.post(
"/instances/{instance_id}/cancel",
response_model=WorkflowInstanceResponse,
)
def api_cancel_instance(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowInstanceResponse:
_require_any_scope(principal, INSTANCE_TRANSITION_SCOPE, ADMIN_SCOPE)
try:
instance = get_instance(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
)
_require_instance_view(instance, principal)
instance = cancel_instance(
session,
tenant_id=principal.tenant_id,
instance_id=instance_id,
actor_id=_actor_id(principal),
principal=principal,
registry=get_registry(),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit_instance(
session,
principal,
action="workflow.instance.cancelled",
instance_id=instance.id,
details={"status": instance.status},
)
response = instance_response(session, instance)
session.commit()
return response
@router.post(
"/definitions",
response_model=WorkflowDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_definition(
payload: WorkflowDefinitionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
canonical_scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
payload = payload.model_copy(update={"scope_id": canonical_scope_id})
definition = create_definition(
session,
tenant_id=tenant_id or principal.tenant_id,
actor_id=_actor_id(principal),
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.created",
definition_id=definition.id,
details={
"key": definition.definition_key,
"revision": definition.current_revision,
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
"definition_kind": definition.definition_kind,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.get(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_get_definition(
definition_id: str,
revision: int | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
return _definition_response(
session,
definition,
principal,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
@router.put(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionResponse,
)
def api_update_definition(
definition_id: str,
payload: WorkflowDefinitionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
tenant_id, scope_type, scope_id, _scope_key = normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
preserve_existing=(
existing.scope_id
if existing.scope_type == scope_type
else None
),
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
definition = update_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.updated",
definition_id=definition.id,
details={
"revision": definition.current_revision,
"status": definition.status,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/derive",
response_model=WorkflowDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_derive_definition(
definition_id: str,
payload: WorkflowDefinitionDeriveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
tenant_id, scope_type, scope_id, _scope_key = (
normalize_definition_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
administrative=has_scope(principal, ADMIN_SCOPE),
)
)
payload = payload.model_copy(
update={"scope_type": scope_type, "scope_id": scope_id}
)
canonical_scope_id = validate_access_scope_reference(
get_registry(),
tenant_id=tenant_id or principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
payload = payload.model_copy(update={"scope_id": canonical_scope_id})
definition = derive_definition(
session,
tenant_id=tenant_id or principal.tenant_id,
actor_id=_actor_id(principal),
principal=principal,
registry=get_registry(),
source_definition_id=definition_id,
payload=payload,
)
except (PermissionError, ValueError) as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.derived",
definition_id=definition.id,
details={
"source_definition_id": (
definition.derived_from_definition_id
),
"source_revision": definition.derived_from_revision,
"source_hash": definition.derived_from_hash,
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.get(
"/definitions/{definition_id}/revisions",
response_model=WorkflowDefinitionRevisionListResponse,
)
def api_list_definition_revisions(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionListResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
revisions = list_definition_revisions(session, definition=definition)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
return WorkflowDefinitionRevisionListResponse(
revisions=[revision_response(item) for item in revisions]
)
@router.get(
"/definitions/{definition_id}/revisions/{revision}",
response_model=WorkflowDefinitionRevisionResponse,
)
def api_get_definition_revision(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionRevisionResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
return revision_response(item)
@router.get(
"/definitions/{definition_id}/revisions/{revision}/bpmn",
response_model=BpmnRevisionDocumentResponse,
)
def api_get_definition_revision_bpmn(
definition_id: str,
revision: int,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> BpmnRevisionDocumentResponse:
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
definition,
principal=principal,
registry=get_registry(),
action="view",
)
item = get_definition_revision(
session,
definition=definition,
revision=revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
summary = revision_bpmn_summary(item)
if summary is None or not item.bpmn_xml:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="This Workflow revision has no BPMN document.",
)
try:
inspection = _bpmn_inspection_response(
item.bpmn_xml,
adapter_id=summary.adapter_id,
adapter_version=summary.adapter_version,
activation=True,
)
except BpmnInspectionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return BpmnRevisionDocumentResponse(
**summary.model_dump(),
definition_id=definition.id,
revision=item.revision,
xml=item.bpmn_xml,
inspection=inspection,
)
@router.post(
"/definitions/{definition_id}/activate",
response_model=WorkflowDefinitionResponse,
)
def api_activate_definition(
definition_id: str,
payload: WorkflowDefinitionActivateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = activate_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
revision=payload.revision,
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.activated",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.post(
"/definitions/{definition_id}/archive",
response_model=WorkflowDefinitionResponse,
)
def api_archive_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = archive_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.archived",
definition_id=definition.id,
details={"active_revision": definition.active_revision},
)
response = _definition_response(session, definition, principal)
session.commit()
return response
@router.delete(
"/definitions/{definition_id}",
response_model=WorkflowDefinitionDeleteResponse,
)
def api_delete_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> WorkflowDefinitionDeleteResponse:
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
try:
existing = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
require_definition_action(
existing,
principal=principal,
registry=get_registry(),
action="edit",
)
definition = delete_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
actor_id=_actor_id(principal),
)
except PermissionError as exc:
raise _governance_http_error(exc) from exc
except WorkflowError as exc:
raise _http_error(exc) from exc
_audit(
session,
principal,
action="workflow.definition.deleted",
definition_id=definition.id,
details={"revision": definition.current_revision},
)
session.commit()
return WorkflowDefinitionDeleteResponse(
deleted=True,
definition_id=definition.id,
)
__all__ = ["router"]