config = new n8n_c(); } /** * @throws Exception */ public function requireModuleEnabled(): void { if (!$this->config->enabled->isTrue()) { throw new Exception('The n8n module is not enabled.'); } } /** * @throws Exception */ public function listWorkflows(array $filters = []): object { return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [ 'active', 'tags', 'name', 'projectId', 'excludePinnedData', 'limit', 'cursor', ])); } /** * @throws Exception */ public function getWorkflow(string $id, bool $excludePinnedData = false): object { $this->requireValidIdentifier($id, 'workflow id'); $query = []; if ($excludePinnedData) { $query['excludePinnedData'] = true; } return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query); } /** * @throws Exception */ public function createWorkflow(object $workflow): object { return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow)); } /** * @throws Exception */ public function updateWorkflow(string $id, object $changes): object { $this->requireValidIdentifier($id, 'workflow id'); $existing = $this->getWorkflow($id); $merged = $this->mergeWorkflowPayload($existing, $changes); return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged); } /** * @throws Exception */ public function publishWorkflow(string $id, ?object $options = null): object { $this->requireValidIdentifier($id, 'workflow id'); return $this->sendApiRequest( 'POST', '/workflows/' . rawurlencode($id) . '/activate', [], $options !== null ? $this->filterPublishOptions($options) : null ); } /** * @throws Exception */ public function deactivateWorkflow(string $id): object { $this->requireValidIdentifier($id, 'workflow id'); return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate'); } /** * @throws Exception */ public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object { $this->requireModuleEnabled(); $url = $this->resolveWebhookUrl($webhookTarget); $normalizedMethod = $this->normalizeMethod($method); return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload); } /** * @throws Exception */ public function listExecutions(array $filters = []): object { return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [ 'includeData', 'status', 'workflowId', 'projectId', 'limit', 'cursor', ])); } /** * @throws Exception */ public function getExecution(int $id, bool $includeData = false): object { $this->requirePositiveInteger($id, 'execution id'); $query = []; if ($includeData) { $query['includeData'] = true; } return $this->sendApiRequest('GET', '/executions/' . $id, $query); } /** * @throws Exception */ public function retryExecution(int $id, bool $loadWorkflow = false): object { $this->requirePositiveInteger($id, 'execution id'); $payload = null; if ($loadWorkflow) { $payload = (object)['loadWorkflow' => true]; } return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload); } /** * @throws Exception */ public function stopExecution(int $id): object { $this->requirePositiveInteger($id, 'execution id'); return $this->sendApiRequest('POST', '/executions/' . $id . '/stop'); } /** * @throws Exception */ private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object { $this->requireModuleEnabled(); $this->requireConfiguredApiUrl(); $this->requireConfiguredApiKey(); $url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query); $headers = [ 'Accept: application/json', 'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()), ]; return $this->executeJsonRequest($method, $url, $headers, $body); } /** * @throws Exception */ private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object { $headers = ['Accept: application/json']; $payload = null; if ($body !== null) { $headers[] = 'Content-Type: application/json'; $payload = json_encode($body, JSON_UNESCAPED_UNICODE); if ($payload === false) { throw new Exception('Unable to encode n8n webhook payload as JSON.'); } } $response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload); if ($response['status'] >= 400) { throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed')); } $decoded = json_decode($response['body']); if (json_last_error() === JSON_ERROR_NONE) { if (is_object($decoded)) { $decoded->status_code = $response['status']; return $decoded; } return (object)[ 'status_code' => $response['status'], 'data' => $decoded, ]; } return (object)[ 'status_code' => $response['status'], 'body' => $response['body'], ]; } /** * @throws Exception */ private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object { $payload = null; if ($body !== null) { $headers[] = 'Content-Type: application/json'; $payload = json_encode($body, JSON_UNESCAPED_UNICODE); if ($payload === false) { throw new Exception('Unable to encode n8n request body as JSON.'); } } $response = $this->executeRequest($method, $url, $headers, $payload); $decoded = json_decode($response['body']); if (json_last_error() !== JSON_ERROR_NONE) { throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').'); } if ($response['status'] >= 400) { throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed')); } if (is_object($decoded)) { return $decoded; } return (object)[ 'data' => $decoded, ]; } /** * @throws Exception */ private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array { $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, ]); if ($body !== null) { curl_setopt($curl, CURLOPT_POSTFIELDS, $body); } $responseBody = curl_exec($curl); $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); $error = curl_error($curl); curl_close($curl); if ($error !== '') { throw new Exception('cURL request to n8n failed: ' . $error); } if ($responseBody === false) { throw new Exception('n8n request returned an empty response.'); } return [ 'status' => $statusCode, 'body' => (string)$responseBody, ]; } private function buildUrl(string $baseUrl, string $path = '', array $query = []): string { $url = rtrim(trim($baseUrl), '/'); if ($path !== '') { $url .= '/' . ltrim($path, '/'); } $query = array_filter($query, static function (mixed $value): bool { return $value !== null && $value !== ''; }); if ($query !== []) { $url .= '?' . http_build_query($query); } return $url; } /** * @throws Exception */ private function resolveWebhookUrl(string $target): string { $target = trim($target); if ($target === '') { throw new Exception('Webhook target must not be empty.'); } $baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue()); if ($baseUrl === '') { throw new Exception('n8n webhook base URL is not configured.'); } if (filter_var($target, FILTER_VALIDATE_URL) !== false) { if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) { throw new Exception('Webhook URL must use the configured n8n webhook host.'); } return $target; } return rtrim($baseUrl, '/') . '/' . ltrim($target, '/'); } private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool { $targetParts = parse_url($targetUrl); $baseParts = parse_url($baseUrl); if (!is_array($targetParts) || !is_array($baseParts)) { return false; } $targetHost = strtolower((string)($targetParts['host'] ?? '')); $baseHost = strtolower((string)($baseParts['host'] ?? '')); if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) { return false; } $targetScheme = strtolower((string)($targetParts['scheme'] ?? '')); $baseScheme = strtolower((string)($baseParts['scheme'] ?? '')); if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) { return false; } $targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80)); $basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80)); return $targetPort === $basePort; } /** * @throws Exception */ private function sanitizeWorkflowPayload(object $workflow): object { $payload = $this->cloneObject($workflow); foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) { if (property_exists($payload, $field)) { unset($payload->{$field}); } } if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') { throw new Exception('Workflow name is required.'); } if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) { throw new Exception('Workflow nodes are required and must be an array.'); } if (!property_exists($payload, 'connections')) { throw new Exception('Workflow connections are required.'); } if (!property_exists($payload, 'settings') || $payload->settings === null) { $payload->settings = new stdClass(); } $payload->connections = $this->normalizeObjectValue($payload->connections, 'connections'); $payload->settings = $this->normalizeObjectValue($payload->settings, 'settings'); if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) { $payload->staticData = $this->arrayToObject($payload->staticData); } return $payload; } private function mergeWorkflowPayload(object $existing, object $changes): object { $merged = $this->cloneObject($existing); foreach (get_object_vars($changes) as $key => $value) { if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) { continue; } if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) { $merged->{$key} = $this->mergeObjects($merged->{$key}, $value); continue; } $merged->{$key} = $value; } return $this->sanitizeWorkflowPayload($merged); } private function mergeObjects(object $base, object $changes): object { foreach (get_object_vars($changes) as $key => $value) { if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) { $base->{$key} = $this->mergeObjects($base->{$key}, $value); continue; } $base->{$key} = $value; } return $base; } /** * @throws Exception */ private function normalizeObjectValue(mixed $value, string $field): object { if ($value instanceof stdClass || is_object($value)) { return $value; } if (is_array($value) && !array_is_list($value)) { return $this->arrayToObject($value); } if ($value === [] && $field === 'settings') { return new stdClass(); } throw new Exception('Workflow ' . $field . ' must be an object.'); } private function arrayToObject(array $value): object { $object = new stdClass(); foreach ($value as $key => $item) { $object->{$key} = $this->normalizeMixedValue($item); } return $object; } private function normalizeMixedValue(mixed $value): mixed { if (!is_array($value)) { return $value; } if (array_is_list($value)) { return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value); } return $this->arrayToObject($value); } private function cloneObject(object $value): object { $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); if ($encoded === false) { return clone $value; } $decoded = json_decode($encoded); return is_object($decoded) ? $decoded : clone $value; } private function filterPublishOptions(object $options): object { $filtered = new stdClass(); foreach (['versionId', 'name', 'description'] as $field) { if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') { $filtered->{$field} = $options->{$field}; } } return $filtered; } private function filterAllowed(array $filters, array $allowedKeys): array { $allowed = array_flip($allowedKeys); $filtered = []; foreach ($filters as $key => $value) { if (isset($allowed[$key])) { $filtered[$key] = $value; } } return $filtered; } /** * @throws Exception */ private function requireConfiguredApiUrl(): void { $url = trim((string)$this->config->api_url->getVariableValue()); if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { throw new Exception('Invalid n8n API URL configured.'); } } /** * @throws Exception */ private function requireConfiguredApiKey(): void { if (trim((string)$this->config->api_key->getVariableValue()) === '') { throw new Exception('Invalid n8n API key configured.'); } } private function normalizeUrlForValidation(string $url): string { if (preg_match('#^https?://#i', $url)) { return $url; } return 'http://' . ltrim($url, '/'); } /** * @throws Exception */ private function requireValidIdentifier(string $value, string $label): void { if (trim($value) === '') { throw new Exception('Invalid ' . $label . '.'); } } /** * @throws Exception */ private function requirePositiveInteger(int $value, string $label): void { if ($value <= 0) { throw new Exception('Invalid ' . $label . '.'); } } private function normalizeMethod(string $method): string { $normalized = strtoupper(trim($method)); if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) { return 'POST'; } return $normalized; } private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string { if (is_object($decoded)) { if (isset($decoded->message) && is_string($decoded->message)) { return $decoded->message; } if (isset($decoded->error) && is_string($decoded->error)) { return $decoded->error; } } if (is_string($decoded) && trim($decoded) !== '') { return $decoded; } return $fallback . ' (HTTP ' . $statusCode . ').'; } }