Skip to content

Commit aac6c73

Browse files
Describe durable service-call snapshots
Describe durable service-call snapshots
1 parent 26747c0 commit aac6c73

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

app/Http/Controllers/Api/ServiceCatalogController.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,44 @@ public function operationUpdate(
593593
return ControlPlaneProtocol::json($this->serializeOperation($operation->refresh(), $endpoint, $service));
594594
}
595595

596+
public function serviceCallShow(
597+
Request $request,
598+
string $endpointName,
599+
string $serviceName,
600+
string $operationName,
601+
string $serviceCallId,
602+
): JsonResponse {
603+
if ($response = ControlPlaneProtocol::rejectUnsupported($request)) {
604+
return $response;
605+
}
606+
607+
$endpoint = $this->findEndpoint($request, $endpointName);
608+
609+
if (! $endpoint) {
610+
return $this->endpointNotFound($request, $endpointName);
611+
}
612+
613+
$service = $this->findService($request, $endpoint, $serviceName);
614+
615+
if (! $service) {
616+
return $this->serviceNotFound($endpoint, $serviceName);
617+
}
618+
619+
$operation = $this->findOperation($request, $service, $operationName);
620+
621+
if (! $operation) {
622+
return $this->operationNotFound($endpoint, $service, $operationName);
623+
}
624+
625+
$serviceCall = $this->findServiceCall($request, $operation, $serviceCallId);
626+
627+
if (! $serviceCall) {
628+
return $this->serviceCallNotFound($endpoint, $service, $operation, $serviceCallId);
629+
}
630+
631+
return ControlPlaneProtocol::json($this->serializeServiceCall($serviceCall, $endpoint, $service, $operation));
632+
}
633+
596634
public function operationDestroy(
597635
Request $request,
598636
string $endpointName,
@@ -750,6 +788,18 @@ private function findOperation(
750788
->first();
751789
}
752790

791+
private function findServiceCall(
792+
Request $request,
793+
WorkflowServiceOperation $operation,
794+
string $serviceCallId,
795+
): ?WorkflowServiceCall {
796+
return WorkflowServiceCall::query()
797+
->where('namespace', $this->namespace($request))
798+
->where('workflow_service_operation_id', $operation->id)
799+
->where('id', trim($serviceCallId))
800+
->first();
801+
}
802+
753803
private function endpointNotFound(Request $request, string $endpointName): JsonResponse
754804
{
755805
$namespace = $this->namespace($request);
@@ -808,6 +858,32 @@ private function operationNotFound(
808858
], 404);
809859
}
810860

861+
private function serviceCallNotFound(
862+
WorkflowServiceEndpoint $endpoint,
863+
WorkflowService $service,
864+
WorkflowServiceOperation $operation,
865+
string $serviceCallId,
866+
): JsonResponse {
867+
$normalizedId = trim($serviceCallId);
868+
869+
return ControlPlaneProtocol::json([
870+
'message' => sprintf(
871+
'Service call [%s] not found under operation [%s] for service [%s] at endpoint [%s] in namespace [%s].',
872+
$normalizedId,
873+
$operation->operation_name,
874+
$service->service_name,
875+
$endpoint->endpoint_name,
876+
$service->namespace,
877+
),
878+
'reason' => 'service_call_not_found',
879+
'namespace' => $service->namespace,
880+
'endpoint_name' => $endpoint->endpoint_name,
881+
'service_name' => $service->service_name,
882+
'operation_name' => $operation->operation_name,
883+
'service_call_id' => $normalizedId,
884+
], 404);
885+
}
886+
811887
/**
812888
* @return array<string, mixed>
813889
*/
@@ -874,6 +950,57 @@ private function serializeOperation(
874950
];
875951
}
876952

953+
/**
954+
* @return array<string, mixed>
955+
*/
956+
private function serializeServiceCall(
957+
WorkflowServiceCall $serviceCall,
958+
WorkflowServiceEndpoint $endpoint,
959+
WorkflowService $service,
960+
WorkflowServiceOperation $operation,
961+
): array {
962+
return [
963+
'id' => $serviceCall->id,
964+
'namespace' => $serviceCall->namespace,
965+
'endpoint_id' => $endpoint->id,
966+
'endpoint_name' => $endpoint->endpoint_name,
967+
'service_id' => $service->id,
968+
'service_name' => $service->service_name,
969+
'operation_id' => $operation->id,
970+
'operation_name' => $operation->operation_name,
971+
'caller_namespace' => $serviceCall->caller_namespace,
972+
'caller_workflow_instance_id' => $serviceCall->caller_workflow_instance_id,
973+
'caller_workflow_run_id' => $serviceCall->caller_workflow_run_id,
974+
'target_namespace' => $serviceCall->target_namespace,
975+
'linked_workflow_instance_id' => $serviceCall->linked_workflow_instance_id,
976+
'linked_workflow_run_id' => $serviceCall->linked_workflow_run_id,
977+
'linked_workflow_update_id' => $serviceCall->linked_workflow_update_id,
978+
'status' => $serviceCall->status,
979+
'operation_mode' => $serviceCall->operation_mode,
980+
'resolved_binding_kind' => $serviceCall->resolved_binding_kind,
981+
'resolved_target_reference' => $serviceCall->resolved_target_reference,
982+
'payload_codec' => $serviceCall->payload_codec,
983+
'input_payload_reference' => $serviceCall->input_payload_reference,
984+
'output_payload_reference' => $serviceCall->output_payload_reference,
985+
'failure_payload_reference' => $serviceCall->failure_payload_reference,
986+
'failure_message' => $serviceCall->failure_message,
987+
'idempotency_key' => $serviceCall->idempotency_key,
988+
'deadline_policy' => $serviceCall->deadline_policy,
989+
'idempotency_policy' => $serviceCall->idempotency_policy,
990+
'cancellation_policy' => $serviceCall->cancellation_policy,
991+
'retry_policy' => $serviceCall->retry_policy,
992+
'boundary_policy' => $serviceCall->boundary_policy,
993+
'metadata' => $serviceCall->metadata,
994+
'accepted_at' => $serviceCall->accepted_at?->toIso8601String(),
995+
'started_at' => $serviceCall->started_at?->toIso8601String(),
996+
'completed_at' => $serviceCall->completed_at?->toIso8601String(),
997+
'failed_at' => $serviceCall->failed_at?->toIso8601String(),
998+
'cancelled_at' => $serviceCall->cancelled_at?->toIso8601String(),
999+
'created_at' => $serviceCall->created_at?->toIso8601String(),
1000+
'updated_at' => $serviceCall->updated_at?->toIso8601String(),
1001+
];
1002+
}
1003+
8771004
private function normalizeCatalogName(string $name): string
8781005
{
8791006
return strtolower($name);

app/Support/RouteAuthorizationResource.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ private function namedIdentifiers(array $identifiers, ?string $operationFamily):
167167
$fields['search_attribute_name'] = $identifiers['name'];
168168
}
169169

170+
if ($operationFamily === 'service') {
171+
if (array_key_exists('endpoint_name', $identifiers)) {
172+
$fields['service_endpoint_name'] = strtolower((string) $identifiers['endpoint_name']);
173+
}
174+
175+
if (array_key_exists('service_name', $identifiers)) {
176+
$fields['service_name'] = strtolower((string) $identifiers['service_name']);
177+
}
178+
179+
if (array_key_exists('operation_name', $identifiers)) {
180+
$fields['service_operation_name'] = strtolower((string) $identifiers['operation_name']);
181+
}
182+
183+
if (array_key_exists('service_call_id', $identifiers)) {
184+
$fields['service_call_id'] = (string) $identifiers['service_call_id'];
185+
}
186+
}
187+
170188
if (array_key_exists('query_name', $identifiers)) {
171189
$fields['query_name'] = $identifiers['query_name'];
172190
}
@@ -205,6 +223,7 @@ private function operationFamilyFromPath(string $path): ?string
205223
'task-queues' => 'task_queue',
206224
'schedules' => 'schedule',
207225
'search-attributes' => 'search_attribute',
226+
'service-endpoints' => 'service',
208227
'system' => 'system',
209228
default => null,
210229
};

routes/api.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
Route::get('/{endpointName}/services/{serviceName}/operations', [ServiceCatalogController::class, 'operationIndex']);
213213
Route::post('/{endpointName}/services/{serviceName}/operations', [ServiceCatalogController::class, 'operationStore']);
214214
Route::get('/{endpointName}/services/{serviceName}/operations/{operationName}', [ServiceCatalogController::class, 'operationShow']);
215+
Route::get('/{endpointName}/services/{serviceName}/operations/{operationName}/service-calls/{serviceCallId}', [ServiceCatalogController::class, 'serviceCallShow']);
215216
Route::put('/{endpointName}/services/{serviceName}/operations/{operationName}', [ServiceCatalogController::class, 'operationUpdate']);
216217
Route::delete('/{endpointName}/services/{serviceName}/operations/{operationName}', [ServiceCatalogController::class, 'operationDestroy']);
217218
});

tests/Feature/AuthNamespaceProtocolOrderingContractTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,37 @@ public function test_custom_provider_can_authorize_worker_task_queue_before_name
545545
$this->assertSame('restricted-queue', $resource['task_queue'] ?? null);
546546
}
547547

548+
public function test_custom_provider_can_authorize_service_routes_with_service_identifiers(): void
549+
{
550+
ResourceAwareAuthProvider::reset();
551+
552+
config(['server.auth.provider' => ResourceAwareAuthProvider::class]);
553+
554+
$response = $this->withHeaders([
555+
'X-Test-Subject' => 'service-admin',
556+
'X-Test-Roles' => 'admin',
557+
'X-Test-Deny-Operation-Family' => 'service',
558+
'X-Namespace' => 'default',
559+
ControlPlaneProtocol::HEADER => ControlPlaneProtocol::VERSION,
560+
])->getJson('/api/service-endpoints/BILLING/services/INVOICING/operations/CREATEINVOICE/service-calls/01JTM2YJJW9K9M8M4J4H1B8S1S');
561+
562+
$response->assertForbidden()
563+
->assertJsonPath('reason', 'forbidden');
564+
565+
$this->assertNoProtocolOrNamespaceReasonLeaked($response);
566+
567+
$resource = ResourceAwareAuthProvider::$lastResource;
568+
569+
$this->assertSame('service', $resource['operation_family'] ?? null);
570+
$this->assertSame('service_call_show', $resource['operation_name'] ?? null);
571+
$this->assertSame('default', $resource['requested_namespace'] ?? null);
572+
$this->assertSame('default', $resource['namespace'] ?? null);
573+
$this->assertSame('billing', $resource['service_endpoint_name'] ?? null);
574+
$this->assertSame('invoicing', $resource['service_name'] ?? null);
575+
$this->assertSame('createinvoice', $resource['service_operation_name'] ?? null);
576+
$this->assertSame('01JTM2YJJW9K9M8M4J4H1B8S1S', $resource['service_call_id'] ?? null);
577+
}
578+
548579
private function configureRoleTokens(): void
549580
{
550581
config([

tests/Feature/ServiceCatalogControllerTest.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Tests\Feature;
44

55
use Illuminate\Foundation\Testing\RefreshDatabase;
6+
use Illuminate\Support\Str;
67
use Tests\Feature\Concerns\ServerTestHelpers;
78
use Tests\TestCase;
89
use Workflow\V2\Models\WorkflowService;
@@ -308,6 +309,94 @@ public function test_it_rejects_duplicate_operation_names_within_the_same_servic
308309
->assertJsonPath('operation_name', 'createinvoice');
309310
}
310311

312+
public function test_it_shows_a_durable_service_call_snapshot(): void
313+
{
314+
$endpoint = WorkflowServiceEndpoint::query()->create([
315+
'namespace' => 'default',
316+
'endpoint_name' => 'billing',
317+
]);
318+
319+
$service = WorkflowService::query()->create([
320+
'namespace' => 'default',
321+
'workflow_service_endpoint_id' => $endpoint->id,
322+
'service_name' => 'invoicing',
323+
]);
324+
325+
$operation = WorkflowServiceOperation::query()->create([
326+
'namespace' => 'default',
327+
'workflow_service_endpoint_id' => $endpoint->id,
328+
'workflow_service_id' => $service->id,
329+
'operation_name' => 'createinvoice',
330+
'operation_mode' => 'async',
331+
'handler_binding_kind' => 'update_workflow',
332+
'handler_target_reference' => 'updates.invoice.submit',
333+
]);
334+
335+
$serviceCall = WorkflowServiceCall::query()->create([
336+
'namespace' => 'default',
337+
'workflow_service_endpoint_id' => $endpoint->id,
338+
'workflow_service_id' => $service->id,
339+
'workflow_service_operation_id' => $operation->id,
340+
'endpoint_name' => $endpoint->endpoint_name,
341+
'service_name' => $service->service_name,
342+
'operation_name' => $operation->operation_name,
343+
'caller_namespace' => 'finance',
344+
'caller_workflow_instance_id' => 'caller-invoice-workflow',
345+
'caller_workflow_run_id' => (string) Str::ulid(),
346+
'target_namespace' => 'default',
347+
'linked_workflow_instance_id' => 'invoice-target-workflow',
348+
'linked_workflow_run_id' => (string) Str::ulid(),
349+
'linked_workflow_update_id' => (string) Str::ulid(),
350+
'status' => 'running',
351+
'operation_mode' => 'async',
352+
'resolved_binding_kind' => 'update_workflow',
353+
'resolved_target_reference' => 'updates.invoice.submit',
354+
'payload_codec' => 'json',
355+
'input_payload_reference' => 'payloads/service-calls/input-1.json',
356+
'output_payload_reference' => 'payloads/service-calls/output-1.json',
357+
'idempotency_key' => 'invoice-123',
358+
'deadline_policy' => ['timeout_seconds' => 60],
359+
'idempotency_policy' => ['scope' => 'caller'],
360+
'cancellation_policy' => ['mode' => 'allow'],
361+
'retry_policy' => ['max_attempts' => 5],
362+
'boundary_policy' => ['visibility' => 'service'],
363+
'metadata' => ['ticket' => 'svc-1'],
364+
'accepted_at' => now()->subMinute(),
365+
'started_at' => now()->subSeconds(15),
366+
]);
367+
368+
$response = $this->withHeaders($this->apiHeaders())
369+
->getJson(sprintf(
370+
'/api/service-endpoints/BILLING/services/INVOICING/operations/CREATEINVOICE/service-calls/%s',
371+
$serviceCall->id,
372+
));
373+
374+
$response->assertOk()
375+
->assertJsonPath('id', $serviceCall->id)
376+
->assertJsonPath('namespace', 'default')
377+
->assertJsonPath('endpoint_name', 'billing')
378+
->assertJsonPath('service_name', 'invoicing')
379+
->assertJsonPath('operation_name', 'createinvoice')
380+
->assertJsonPath('caller_namespace', 'finance')
381+
->assertJsonPath('target_namespace', 'default')
382+
->assertJsonPath('status', 'running')
383+
->assertJsonPath('operation_mode', 'async')
384+
->assertJsonPath('resolved_binding_kind', 'update_workflow')
385+
->assertJsonPath('resolved_target_reference', 'updates.invoice.submit')
386+
->assertJsonPath('payload_codec', 'json')
387+
->assertJsonPath('input_payload_reference', 'payloads/service-calls/input-1.json')
388+
->assertJsonPath('output_payload_reference', 'payloads/service-calls/output-1.json')
389+
->assertJsonPath('idempotency_key', 'invoice-123')
390+
->assertJsonPath('deadline_policy.timeout_seconds', 60)
391+
->assertJsonPath('idempotency_policy.scope', 'caller')
392+
->assertJsonPath('cancellation_policy.mode', 'allow')
393+
->assertJsonPath('retry_policy.max_attempts', 5)
394+
->assertJsonPath('boundary_policy.visibility', 'service')
395+
->assertJsonPath('metadata.ticket', 'svc-1')
396+
->assertJsonPath('accepted_at', $serviceCall->accepted_at?->toIso8601String())
397+
->assertJsonPath('started_at', $serviceCall->started_at?->toIso8601String());
398+
}
399+
311400
public function test_it_requires_a_handler_target_reference_or_non_empty_handler_binding_for_operations(): void
312401
{
313402
$endpoint = WorkflowServiceEndpoint::query()->create([

0 commit comments

Comments
 (0)