Skip to content

Commit 32cb01b

Browse files
committed
Implement Phases 1, 3, 4, 7: Remove Laravel coupling, normalize events, clean API, add event spy
Phase 1 — Remove Laravel coupling: - Add Support\Arr helper for dot-notation array access - Replace data_get() calls in Step and WorkflowDefinition with Arr::get() - Remove getNestedValue() from WorkflowDefinition - Fix HttpAction to use curl instead of Laravel Http facade - Remove Laravel-specific PHPStan suppressions Phase 3 — Normalize event naming: - Rename WorkflowStarted → WorkflowStartedEvent - Rename WorkflowCancelled → WorkflowCancelledEvent - Standardize constructors to accept WorkflowInstance Phase 4 — Clean up duplicate API methods: - Remove getWorkflow() (duplicate of getInstance()) - Remove listWorkflows() (duplicate of getInstances()) - Add WorkflowState enum conversion to getInstances() Phase 7 — Add SpyEventDispatcher: - Create tests/Support/SpyEventDispatcher for event verification - Add EventDispatchTest integration tests - Add ArrTest unit tests https://claude.ai/code/session_013CdFJtmtMmSqBZFwdrmicG
1 parent 5eba917 commit 32cb01b

File tree

15 files changed

+930
-126
lines changed

15 files changed

+930
-126
lines changed

PLAN.md

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

phpstan.neon.dist

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ parameters:
44
- src
55
tmpDir: build/phpstan
66
ignoreErrors:
7-
- '#Function (data_get|data_set|class_basename) not found#'
8-
- '#Call to static method timeout\(\) on an unknown class Illuminate\\Support\\Facades\\Http#'
97
- '#has no value type specified in iterable type array#'
108
- '#Match arm comparison between .+ and .+ is always true#'
119
- '#has parameter .+ with no type specified#'

src/Actions/HttpAction.php

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
namespace SolutionForest\WorkflowEngine\Actions;
44

5-
use Illuminate\Support\Facades\Http;
65
use SolutionForest\WorkflowEngine\Attributes\Retry;
76
use SolutionForest\WorkflowEngine\Attributes\Timeout;
87
use SolutionForest\WorkflowEngine\Attributes\WorkflowStep;
98
use SolutionForest\WorkflowEngine\Core\ActionResult;
109
use SolutionForest\WorkflowEngine\Core\WorkflowContext;
10+
use SolutionForest\WorkflowEngine\Support\Arr;
1111

1212
/**
1313
* HTTP request action with PHP 8.3+ features
@@ -48,30 +48,90 @@ protected function doExecute(WorkflowContext $context): ActionResult
4848
$data = $this->processArrayTemplates($data, $context->getData());
4949

5050
try {
51-
$response = match ($method) {
52-
'GET' => Http::timeout($timeout)->withHeaders($headers)->get($url, $data),
53-
'POST' => Http::timeout($timeout)->withHeaders($headers)->post($url, $data),
54-
'PUT' => Http::timeout($timeout)->withHeaders($headers)->put($url, $data),
55-
'PATCH' => Http::timeout($timeout)->withHeaders($headers)->patch($url, $data),
56-
'DELETE' => Http::timeout($timeout)->withHeaders($headers)->delete($url, $data),
57-
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}")
58-
};
59-
60-
if ($response->successful()) {
51+
// Initialize cURL
52+
$ch = curl_init();
53+
54+
// Build query string for GET requests
55+
if ($method === 'GET' && ! empty($data)) {
56+
$url .= '?' . http_build_query($data);
57+
}
58+
59+
curl_setopt($ch, CURLOPT_URL, $url);
60+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
61+
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
62+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
63+
64+
// Set HTTP method and body
65+
if ($method !== 'GET') {
66+
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
67+
if (! empty($data)) {
68+
$jsonData = json_encode($data);
69+
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
70+
$headers['Content-Type'] = 'application/json';
71+
$headers['Content-Length'] = strlen($jsonData);
72+
}
73+
}
74+
75+
// Set headers
76+
if (! empty($headers)) {
77+
$headerArray = [];
78+
foreach ($headers as $key => $value) {
79+
$headerArray[] = "{$key}: {$value}";
80+
}
81+
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerArray);
82+
}
83+
84+
// Capture response headers
85+
$responseHeaders = [];
86+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
87+
$len = strlen($header);
88+
$header = explode(':', $header, 2);
89+
if (count($header) >= 2) {
90+
$responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
91+
}
92+
93+
return $len;
94+
});
95+
96+
// Execute request
97+
$responseBody = curl_exec($ch);
98+
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
99+
$error = curl_error($ch);
100+
curl_close($ch);
101+
102+
if ($error) {
103+
return ActionResult::failure(
104+
"HTTP request failed: {$error}",
105+
[
106+
'error' => $error,
107+
'url' => $url,
108+
'method' => $method,
109+
]
110+
);
111+
}
112+
113+
// Parse JSON response
114+
$responseData = json_decode($responseBody, true);
115+
if (json_last_error() !== JSON_ERROR_NONE) {
116+
$responseData = $responseBody;
117+
}
118+
119+
// Check if successful (2xx status code)
120+
if ($statusCode >= 200 && $statusCode < 300) {
61121
return ActionResult::success([
62-
'status_code' => $response->status(),
63-
'response_data' => $response->json(),
64-
'headers' => $response->headers(),
122+
'status_code' => $statusCode,
123+
'response_data' => $responseData,
124+
'headers' => $responseHeaders,
65125
'url' => $url,
66126
'method' => $method,
67127
]);
68128
}
69129

70130
return ActionResult::failure(
71-
"HTTP request failed with status {$response->status()}: {$response->body()}",
131+
"HTTP request failed with status {$statusCode}",
72132
[
73-
'status_code' => $response->status(),
74-
'response_body' => $response->body(),
133+
'status_code' => $statusCode,
134+
'response_body' => $responseBody,
75135
'url' => $url,
76136
'method' => $method,
77137
]
@@ -95,7 +155,7 @@ protected function doExecute(WorkflowContext $context): ActionResult
95155
private function processTemplate(string $template, array $data): string
96156
{
97157
return preg_replace_callback('/\{\{\s*([^}]+)\s*\}\}/', function ($matches) use ($data) {
98-
return data_get($data, trim($matches[1]), $matches[0]);
158+
return Arr::get($data, trim($matches[1]), $matches[0]);
99159
}, $template);
100160
}
101161

src/Core/Step.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace SolutionForest\WorkflowEngine\Core;
44

5+
use SolutionForest\WorkflowEngine\Support\Arr;
6+
57
/**
68
* Represents a single step in a workflow with complete configuration and execution metadata.
79
*
@@ -226,7 +228,7 @@ private function evaluateCondition(string $condition, array $data): bool
226228
$operator = $matches[2];
227229
$value = trim($matches[3], '"\'');
228230

229-
$dataValue = data_get($data, $key);
231+
$dataValue = Arr::get($data, $key);
230232

231233
return match ($operator) {
232234
'===' => $dataValue === $value,

src/Core/WorkflowDefinition.php

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace SolutionForest\WorkflowEngine\Core;
44

55
use SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException;
6+
use SolutionForest\WorkflowEngine\Support\Arr;
67

78
/**
89
* Represents a complete workflow definition with steps, transitions, and metadata.
@@ -328,7 +329,7 @@ private function evaluateCondition(string $condition, array $data): bool
328329
$operator = $matches[2];
329330
$value = trim($matches[3], '"\'');
330331

331-
$dataValue = $this->getNestedValue($data, $key);
332+
$dataValue = Arr::get($data, $key);
332333

333334
return match ($operator) {
334335
'===' => $dataValue === $value,
@@ -395,28 +396,4 @@ public static function fromArray(array $data): static
395396
);
396397
}
397398

398-
/**
399-
* Get a nested value from an array using dot notation.
400-
*
401-
* @param array<string, mixed> $array
402-
*/
403-
private function getNestedValue(array $array, string $key, $default = null)
404-
{
405-
if (isset($array[$key])) {
406-
return $array[$key];
407-
}
408-
409-
// Handle dot notation for nested arrays
410-
$keys = explode('.', $key);
411-
$value = $array;
412-
413-
foreach ($keys as $nestedKey) {
414-
if (! is_array($value) || ! array_key_exists($nestedKey, $value)) {
415-
return $default;
416-
}
417-
$value = $value[$nestedKey];
418-
}
419-
420-
return $value;
421-
}
422399
}

src/Core/WorkflowEngine.php

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
use SolutionForest\WorkflowEngine\Contracts\EventDispatcher;
66
use SolutionForest\WorkflowEngine\Contracts\StorageAdapter;
7-
use SolutionForest\WorkflowEngine\Events\WorkflowCancelled;
8-
use SolutionForest\WorkflowEngine\Events\WorkflowStarted;
7+
use SolutionForest\WorkflowEngine\Events\WorkflowCancelledEvent;
8+
use SolutionForest\WorkflowEngine\Events\WorkflowStartedEvent;
99
use SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException;
1010
use SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowStateException;
1111
use SolutionForest\WorkflowEngine\Exceptions\WorkflowInstanceNotFoundException;
@@ -132,9 +132,8 @@ public function start(string $workflowId, array $definition, array $context = []
132132
$this->stateManager->save($instance);
133133

134134
// Dispatch start event
135-
$this->dispatchEvent(new WorkflowStarted(
136-
$instance->getId(),
137-
$instance->getDefinition()->getName(),
135+
$this->dispatchEvent(new WorkflowStartedEvent(
136+
$instance,
138137
$context
139138
));
140139

@@ -237,6 +236,11 @@ public function getInstance(string $instanceId): WorkflowInstance
237236
*/
238237
public function getInstances(array $filters = []): array
239238
{
239+
// Convert WorkflowState enum to string value for storage layer
240+
if (isset($filters['state']) && $filters['state'] instanceof WorkflowState) {
241+
$filters['state'] = $filters['state']->value;
242+
}
243+
240244
return $this->storage->findInstances($filters);
241245
}
242246

@@ -250,29 +254,20 @@ public function cancel(string $instanceId, string $reason = ''): WorkflowInstanc
250254
$this->stateManager->save($instance);
251255

252256
// Dispatch cancel event
253-
$this->dispatchEvent(new WorkflowCancelled(
254-
$instance->getId(),
255-
$instance->getDefinition()->getName(),
257+
$this->dispatchEvent(new WorkflowCancelledEvent(
258+
$instance,
256259
$reason
257260
));
258261

259262
return $instance;
260263
}
261264

262-
/**
263-
* Get workflow instance by ID
264-
*/
265-
public function getWorkflow(string $workflowId): WorkflowInstance
266-
{
267-
return $this->stateManager->load($workflowId);
268-
}
269-
270265
/**
271266
* Get workflow status
272267
*/
273268
public function getStatus(string $workflowId): array
274269
{
275-
$instance = $this->getWorkflow($workflowId);
270+
$instance = $this->getInstance($workflowId);
276271

277272
return [
278273
'workflow_id' => $instance->getId(),
@@ -285,34 +280,6 @@ public function getStatus(string $workflowId): array
285280
];
286281
}
287282

288-
/**
289-
* List workflows with optional filters
290-
*
291-
* @param array<string, mixed> $filters
292-
* @return array<int, array<string, mixed>>
293-
*/
294-
public function listWorkflows(array $filters = []): array
295-
{
296-
// Convert WorkflowState enum to string value for storage layer
297-
if (isset($filters['state']) && $filters['state'] instanceof WorkflowState) {
298-
$filters['state'] = $filters['state']->value;
299-
}
300-
301-
$instances = $this->storage->findInstances($filters);
302-
303-
return array_map(function (WorkflowInstance $instance) {
304-
return [
305-
'workflow_id' => $instance->getId(),
306-
'name' => $instance->getDefinition()->getName(),
307-
'state' => $instance->getState()->value,
308-
'current_step' => $instance->getCurrentStepId(),
309-
'progress' => $instance->getProgress(),
310-
'created_at' => $instance->getCreatedAt(),
311-
'updated_at' => $instance->getUpdatedAt(),
312-
];
313-
}, $instances);
314-
}
315-
316283
/**
317284
* Safely dispatch an event if event dispatcher is available
318285
*/

src/Events/WorkflowCancelled.php

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace SolutionForest\WorkflowEngine\Events;
4+
5+
use SolutionForest\WorkflowEngine\Core\WorkflowInstance;
6+
7+
final readonly class WorkflowCancelledEvent
8+
{
9+
public function __construct(
10+
public WorkflowInstance $instance,
11+
public string $reason = '',
12+
) {}
13+
14+
public function getWorkflowId(): string
15+
{
16+
return $this->instance->getId();
17+
}
18+
19+
public function getWorkflowName(): string
20+
{
21+
return $this->instance->getDefinition()->getName();
22+
}
23+
}

src/Events/WorkflowStarted.php

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace SolutionForest\WorkflowEngine\Events;
4+
5+
use SolutionForest\WorkflowEngine\Core\WorkflowInstance;
6+
7+
final readonly class WorkflowStartedEvent
8+
{
9+
/**
10+
* @param array<string, mixed> $initialContext
11+
*/
12+
public function __construct(
13+
public WorkflowInstance $instance,
14+
public array $initialContext = [],
15+
) {}
16+
17+
public function getWorkflowId(): string
18+
{
19+
return $this->instance->getId();
20+
}
21+
22+
public function getWorkflowName(): string
23+
{
24+
return $this->instance->getDefinition()->getName();
25+
}
26+
}

0 commit comments

Comments
 (0)