Skip to content

Commit 398b74d

Browse files
GitHub #56: TD-061: Implement the cross-namespace service layer in 2.0 (#528)
1 parent b93cf36 commit 398b74d

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

src/V2/Support/DefaultServiceControlPlane.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Workflow\V2\Support;
66

77
use DateTimeInterface;
8+
use Illuminate\Support\Str;
89
use Throwable;
910
use Workflow\V2\Contracts\ServiceBoundaryPolicy;
1011
use Workflow\V2\Contracts\ServiceControlPlane;
@@ -733,6 +734,18 @@ private function dispatchHandler(
733734
$request,
734735
$options,
735736
),
737+
ServiceCallBindingKind::ActivityExecution->value => $this->dispatchActivityExecution(
738+
$call,
739+
$operation,
740+
$request,
741+
$options,
742+
),
743+
ServiceCallBindingKind::InvocableCarrierRequest->value, 'invocable_http' => $this->dispatchInvocableCarrierRequest(
744+
$call,
745+
$operation,
746+
$request,
747+
$options,
748+
),
736749
default => null,
737750
};
738751
}
@@ -979,6 +992,137 @@ private function dispatchWorkflowQuery(
979992
);
980993
}
981994

995+
/**
996+
* @param array<string, mixed> $options
997+
* @return array<string, mixed>
998+
*/
999+
private function dispatchActivityExecution(
1000+
WorkflowServiceCall $call,
1001+
WorkflowServiceOperation $operation,
1002+
ServiceBoundaryRequest $request,
1003+
array $options,
1004+
): array {
1005+
$binding = $this->handlerBinding($operation);
1006+
$activityClass = $this->bindingString($binding, ['activity_class', 'class'])
1007+
?? $this->filledString($operation->handler_target_reference);
1008+
$activityType = $this->bindingString($binding, ['activity_type', 'type'])
1009+
?? $activityClass;
1010+
1011+
if ($activityType === null) {
1012+
return $this->markHandlerFailure(
1013+
$call,
1014+
$request,
1015+
'handler_target_missing',
1016+
'Activity binding has no activity class or type.',
1017+
);
1018+
}
1019+
1020+
$activityExecutionId = $this->bindingString($binding, ['activity_execution_id'])
1021+
?? $this->stringOption($options, 'activity_execution_id')
1022+
?? (string) Str::ulid();
1023+
1024+
$linkedInstanceId = $this->bindingString($binding, ['workflow_instance_id', 'owning_workflow_instance_id'])
1025+
?? $this->stringOption($options, 'owning_workflow_instance_id');
1026+
$linkedRunId = $this->bindingString($binding, ['workflow_run_id', 'owning_workflow_run_id'])
1027+
?? $this->stringOption($options, 'owning_workflow_run_id');
1028+
1029+
$metadata = [
1030+
'activity_execution_id' => $activityExecutionId,
1031+
'activity_class' => $activityClass,
1032+
'activity_type' => $activityType,
1033+
];
1034+
1035+
foreach (['connection', 'queue'] as $key) {
1036+
$value = $this->bindingString($binding, [$key]) ?? $this->stringOption($options, $key);
1037+
if ($value !== null) {
1038+
$metadata[$key] = $value;
1039+
}
1040+
}
1041+
1042+
$this->markStarted(
1043+
$call,
1044+
ServiceCallBindingKind::ActivityExecution->value,
1045+
$activityExecutionId,
1046+
$linkedInstanceId,
1047+
$linkedRunId,
1048+
null,
1049+
$metadata,
1050+
);
1051+
1052+
return [
1053+
'accepted' => true,
1054+
'kind' => ServiceCallBindingKind::ActivityExecution->value,
1055+
'activity_execution_id' => $activityExecutionId,
1056+
'activity_class' => $activityClass,
1057+
'activity_type' => $activityType,
1058+
];
1059+
}
1060+
1061+
/**
1062+
* @param array<string, mixed> $options
1063+
* @return array<string, mixed>
1064+
*/
1065+
private function dispatchInvocableCarrierRequest(
1066+
WorkflowServiceCall $call,
1067+
WorkflowServiceOperation $operation,
1068+
ServiceBoundaryRequest $request,
1069+
array $options,
1070+
): array {
1071+
$binding = $this->handlerBinding($operation);
1072+
$carrierEndpoint = $this->bindingString($binding, ['carrier_endpoint', 'endpoint', 'url'])
1073+
?? $this->filledString($operation->handler_target_reference);
1074+
$carrierHandler = $this->bindingString($binding, ['carrier_handler', 'handler', 'handler_name']);
1075+
$carrierName = $this->bindingString($binding, ['carrier', 'carrier_name']);
1076+
1077+
if ($carrierEndpoint === null) {
1078+
return $this->markHandlerFailure(
1079+
$call,
1080+
$request,
1081+
'handler_target_missing',
1082+
'Invocable carrier binding has no endpoint or carrier reference.',
1083+
);
1084+
}
1085+
1086+
$carrierRequestId = $this->bindingString($binding, ['carrier_request_id'])
1087+
?? $this->stringOption($options, 'carrier_request_id')
1088+
?? (string) Str::ulid();
1089+
1090+
$linkedInstanceId = $this->bindingString($binding, ['workflow_instance_id', 'bound_workflow_instance_id'])
1091+
?? $this->stringOption($options, 'bound_workflow_instance_id');
1092+
1093+
$metadata = [
1094+
'carrier_request_id' => $carrierRequestId,
1095+
'carrier_endpoint' => $carrierEndpoint,
1096+
];
1097+
1098+
if ($carrierHandler !== null) {
1099+
$metadata['carrier_handler'] = $carrierHandler;
1100+
}
1101+
1102+
if ($carrierName !== null) {
1103+
$metadata['carrier'] = $carrierName;
1104+
}
1105+
1106+
$this->markStarted(
1107+
$call,
1108+
ServiceCallBindingKind::InvocableCarrierRequest->value,
1109+
$carrierRequestId,
1110+
$linkedInstanceId,
1111+
null,
1112+
null,
1113+
$metadata,
1114+
);
1115+
1116+
return [
1117+
'accepted' => true,
1118+
'kind' => ServiceCallBindingKind::InvocableCarrierRequest->value,
1119+
'carrier_request_id' => $carrierRequestId,
1120+
'carrier_endpoint' => $carrierEndpoint,
1121+
'carrier_handler' => $carrierHandler,
1122+
'carrier' => $carrierName,
1123+
];
1124+
}
1125+
9821126
/**
9831127
* @param array<string, mixed> $metadata
9841128
*/

tests/Unit/V2/DefaultServiceControlPlaneTest.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,142 @@ public function testExplicitPreAdmittedCallStillDispatchesWhenIdempotencyKeyIsPr
305305
$this->assertCount(1, $fakeWorkflow->starts);
306306
}
307307

308+
public function testActivityBindingMarksCallStartedWithGeneratedActivityExecutionReference(): void
309+
{
310+
$controlPlane = new DefaultServiceControlPlane(
311+
new FakeServiceWorkflowControlPlane(),
312+
new DefaultServiceBoundaryPolicy(),
313+
);
314+
[$endpoint, $service] = $this->catalog('billing');
315+
316+
$this->operation($endpoint, $service, [
317+
'handler_binding_kind' => ServiceCallBindingKind::ActivityExecution->value,
318+
'handler_target_reference' => 'billing.invoice.activity',
319+
'handler_binding' => [
320+
'activity_class' => 'App\\Activities\\IssueInvoice',
321+
'activity_type' => 'billing.invoice.issue',
322+
'queue' => 'invoices-priority',
323+
],
324+
]);
325+
326+
$result = $controlPlane->execute('billing', 'invoices', 'create', [
327+
'namespace' => 'billing',
328+
'caller_namespace' => 'finance',
329+
'principal_subject' => 'user:operator',
330+
]);
331+
332+
$this->assertTrue($result['accepted']);
333+
$this->assertSame(ServiceCallStatus::Started->value, $result['status']);
334+
$this->assertSame(ServiceCallBindingKind::ActivityExecution->value, $result['resolved_binding_kind']);
335+
$this->assertNotNull($result['resolved_target_reference']);
336+
$this->assertSame($result['resolved_target_reference'], $result['handler']['activity_execution_id']);
337+
$this->assertSame('App\\Activities\\IssueInvoice', $result['handler']['activity_class']);
338+
$this->assertSame('billing.invoice.issue', $result['handler']['activity_type']);
339+
$this->assertSame(ServiceCallBindingKind::ActivityExecution->value, $result['handler']['kind']);
340+
341+
$call = WorkflowServiceCall::query()->firstOrFail();
342+
343+
$this->assertSame(ServiceCallStatus::Started->value, $call->status);
344+
$this->assertSame(ServiceCallOutcome::Accepted, $call->outcome);
345+
$this->assertSame($result['resolved_target_reference'], $call->resolved_target_reference);
346+
$this->assertSame($result['resolved_target_reference'], $call->metadata['activity_execution_id']);
347+
$this->assertSame('App\\Activities\\IssueInvoice', $call->metadata['activity_class']);
348+
$this->assertSame('billing.invoice.issue', $call->metadata['activity_type']);
349+
$this->assertSame('invoices-priority', $call->metadata['queue']);
350+
}
351+
352+
public function testActivityBindingFailsWhenActivityClassAndReferenceMissing(): void
353+
{
354+
$controlPlane = new DefaultServiceControlPlane(
355+
new FakeServiceWorkflowControlPlane(),
356+
new DefaultServiceBoundaryPolicy(),
357+
);
358+
[$endpoint, $service] = $this->catalog('billing');
359+
360+
$this->operation($endpoint, $service, [
361+
'handler_binding_kind' => ServiceCallBindingKind::ActivityExecution->value,
362+
'handler_target_reference' => null,
363+
'handler_binding' => [],
364+
]);
365+
366+
$result = $controlPlane->execute('billing', 'invoices', 'create', [
367+
'namespace' => 'billing',
368+
]);
369+
370+
$this->assertFalse($result['accepted']);
371+
$this->assertSame('handler_target_missing', $result['reason']);
372+
$this->assertSame(ServiceCallStatus::Failed->value, $result['status']);
373+
$this->assertSame(ServiceCallOutcome::HandlerFailed->value, $result['outcome']);
374+
}
375+
376+
public function testInvocableCarrierBindingMarksCallStartedWithGeneratedCarrierRequestReference(): void
377+
{
378+
$controlPlane = new DefaultServiceControlPlane(
379+
new FakeServiceWorkflowControlPlane(),
380+
new DefaultServiceBoundaryPolicy(),
381+
);
382+
[$endpoint, $service] = $this->catalog('billing');
383+
384+
$this->operation($endpoint, $service, [
385+
'handler_binding_kind' => 'invocable_http',
386+
'handler_target_reference' => 'https://carrier.billing.example/handle',
387+
'handler_binding' => [
388+
'carrier' => 'php-invocable',
389+
'carrier_handler' => 'billing.invoice.issue',
390+
'workflow_instance_id' => 'invoice-42',
391+
],
392+
]);
393+
394+
$result = $controlPlane->execute('billing', 'invoices', 'create', [
395+
'namespace' => 'billing',
396+
'caller_namespace' => 'finance',
397+
'principal_subject' => 'user:operator',
398+
]);
399+
400+
$this->assertTrue($result['accepted']);
401+
$this->assertSame(ServiceCallStatus::Started->value, $result['status']);
402+
$this->assertSame(ServiceCallBindingKind::InvocableCarrierRequest->value, $result['resolved_binding_kind']);
403+
$this->assertNotNull($result['resolved_target_reference']);
404+
$this->assertSame($result['resolved_target_reference'], $result['handler']['carrier_request_id']);
405+
$this->assertSame('https://carrier.billing.example/handle', $result['handler']['carrier_endpoint']);
406+
$this->assertSame('billing.invoice.issue', $result['handler']['carrier_handler']);
407+
$this->assertSame('php-invocable', $result['handler']['carrier']);
408+
$this->assertSame('invoice-42', $result['linked_workflow_instance_id']);
409+
410+
$call = WorkflowServiceCall::query()->firstOrFail();
411+
412+
$this->assertSame(ServiceCallStatus::Started->value, $call->status);
413+
$this->assertSame(ServiceCallOutcome::Accepted, $call->outcome);
414+
$this->assertSame($result['resolved_target_reference'], $call->metadata['carrier_request_id']);
415+
$this->assertSame('https://carrier.billing.example/handle', $call->metadata['carrier_endpoint']);
416+
$this->assertSame('billing.invoice.issue', $call->metadata['carrier_handler']);
417+
$this->assertSame('php-invocable', $call->metadata['carrier']);
418+
}
419+
420+
public function testInvocableCarrierBindingFailsWhenEndpointAndReferenceMissing(): void
421+
{
422+
$controlPlane = new DefaultServiceControlPlane(
423+
new FakeServiceWorkflowControlPlane(),
424+
new DefaultServiceBoundaryPolicy(),
425+
);
426+
[$endpoint, $service] = $this->catalog('billing');
427+
428+
$this->operation($endpoint, $service, [
429+
'handler_binding_kind' => 'invocable_http',
430+
'handler_target_reference' => null,
431+
'handler_binding' => [],
432+
]);
433+
434+
$result = $controlPlane->execute('billing', 'invoices', 'create', [
435+
'namespace' => 'billing',
436+
]);
437+
438+
$this->assertFalse($result['accepted']);
439+
$this->assertSame('handler_target_missing', $result['reason']);
440+
$this->assertSame(ServiceCallStatus::Failed->value, $result['status']);
441+
$this->assertSame(ServiceCallOutcome::HandlerFailed->value, $result['outcome']);
442+
}
443+
308444
public function testCancelCallHonorsCancellationPolicy(): void
309445
{
310446
$controlPlane = new DefaultServiceControlPlane(

0 commit comments

Comments
 (0)