current_node_id; $context = $run->context ?? []; if ($url === '') { return NodeRunResult::failed('HTTP request node missing url.'); } $resolvedUrl = $this->resolver->resolve($url, $context); $request = $this->buildRequest($config, $context); $body = $this->buildJsonBody($method, $config, $context); try { $response = match ($method) { 'GET' => $request->get($resolvedUrl), 'DELETE' => $request->delete($resolvedUrl), 'POST' => $request->post($resolvedUrl, $body), 'PUT' => $request->put($resolvedUrl, $body), 'PATCH' => $request->patch($resolvedUrl, $body), default => null, }; } catch (Throwable $e) { return NodeRunResult::failed('HTTP request threw an exception.', ['message' => $e->getMessage()]); } if ($response === null) { return NodeRunResult::failed("Unsupported HTTP method: {$method}"); } if (! $response->successful()) { return NodeRunResult::failed('HTTP request failed.', [ 'status' => $response->status(), 'body' => substr($response->body(), 0, 500), ]); } $body = $response->json(); $useItems = is_string($itemsPath) && $itemsPath !== ''; if (! $useItems) { // Single-response mode: pass the whole body forward as `fetched`. return NodeRunResult::completed([ 'fetch' => ['count' => 1, 'spawned' => 0], 'fetched' => $body, ]); } $rawItems = data_get($body, $itemsPath, []); if (! is_array($rawItems)) { return NodeRunResult::failed('Items path did not resolve to an array.'); } // Dry runs bypass the watermark entirely: they neither load nor advance // the persisted state, AND they process every item so the user actually // sees data flow (with a "now" watermark, fresh tests would yield 0 // items on any feed older than today and look broken). $useWatermark = is_string($itemDatePath) && $itemDatePath !== '' && ! $run->is_dry_run; $state = $useWatermark ? AutomationNodeState::for($run->automation_id, $nodeId) : null; $watermark = $useWatermark ? $this->parseWatermark(data_get($state->data, 'last_item_date')) : null; $newestSeen = null; $newItems = []; foreach ($rawItems as $item) { if (! is_array($item)) { continue; } if ($useWatermark) { $itemDate = $this->parseDate(data_get($item, $itemDatePath)); if ($itemDate === null) { continue; } if ($newestSeen === null || $itemDate->greaterThan($newestSeen)) { $newestSeen = $itemDate; } if (! $itemDate->greaterThan($watermark)) { continue; } } $key = $itemKeyPath ? data_get($item, $itemKeyPath) : null; $key = ($key === null || $key === '') ? hash('sha256', json_encode($item)) : (string) $key; $newItems[] = array_merge($item, ['_key' => $key]); } if ($useWatermark && $newestSeen !== null && $state !== null) { $state->update(['data' => array_merge($state->data ?? [], [ 'last_item_date' => $newestSeen->toIso8601String(), ])]); } if ($newItems === []) { return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items'); } $first = array_shift($newItems); // Dry runs skip sibling spawning so the test stays a single in-memory // walk through one item — see TestAutomation's dry-run contract. if (! $run->is_dry_run) { $this->spawnSiblings($run, $nodeId, $newItems); } return NodeRunResult::completed([ 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)], 'fetched' => $first, ]); } /** * @param array $config * @param array $context */ private function buildRequest(array $config, array $context): PendingRequest { $request = Http::asJson(); $headers = []; foreach ((array) data_get($config, 'headers', []) as $k => $v) { $headers[$k] = $this->resolver->resolve((string) $v, $context); } $authType = data_get($config, 'auth_type', 'none'); if ($authType === 'bearer') { $token = $this->decrypt((string) data_get($config, 'auth_token', '')); if ($token !== '') { $request = $request->withToken($this->resolver->resolve($token, $context)); } } elseif ($authType === 'basic') { $user = (string) data_get($config, 'auth_username', ''); $pass = $this->decrypt((string) data_get($config, 'auth_password', '')); if ($user !== '' || $pass !== '') { $request = $request->withBasicAuth( $this->resolver->resolve($user, $context), $this->resolver->resolve($pass, $context), ); } } elseif ($authType === 'api_key') { $headerName = (string) data_get($config, 'auth_header_name', 'X-API-Key'); $token = $this->decrypt((string) data_get($config, 'auth_token', '')); if ($token !== '') { $headers[$headerName] = $this->resolver->resolve($token, $context); } } if ($headers !== []) { $request = $request->withHeaders($headers); } return $request; } /** * @param array $config * @param array $context * @return array */ private function buildJsonBody(string $method, array $config, array $context): array { if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { return []; } $template = (string) data_get($config, 'body_template', ''); if ($template === '') { return []; } $rendered = $this->resolver->resolve($template, $context); $decoded = json_decode($rendered, true); return is_array($decoded) ? $decoded : []; } /** * @param array> $items */ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void { if ($items === []) { return; } $nextNodeId = $this->findNextNodeId($parent, $fetchNodeId); foreach ($items as $item) { $sibling = AutomationRun::create([ 'automation_id' => $parent->automation_id, 'is_manual' => $parent->is_manual, 'is_dry_run' => $parent->is_dry_run, 'status' => RunStatus::Pending, 'context' => array_merge($parent->context ?? [], ['fetched' => $item]), ]); if ($nextNodeId === null) { $sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]); continue; } ProcessAutomationNode::dispatch($sibling, $nextNodeId); } } private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string { $connection = collect($run->automation->connections ?? []) ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default'); return $connection['target'] ?? null; } private function decrypt(string $value): string { if ($value === '') { return ''; } try { return Crypt::decryptString($value); } catch (Throwable) { // Value isn't an encrypted payload (legacy plain text or already decrypted). return $value; } } private function parseWatermark(?string $stored): CarbonImmutable { if ($stored === null) { return CarbonImmutable::now(); } try { return CarbonImmutable::parse($stored); } catch (Throwable) { return CarbonImmutable::now(); } } private function parseDate(mixed $raw): ?CarbonImmutable { if (! is_string($raw) && ! is_numeric($raw)) { return null; } try { return CarbonImmutable::parse((string) $raw); } catch (Throwable) { return null; } } }