Files
api/services/nginx/app/classes/n8n.php
T

588 lines
16 KiB
PHP

<?php
namespace classes;
require_once WD . '/modules/n8n/n8n_c.php';
use Exception;
use interfaces\n8n_i;
use n8n\n8n_c;
use stdClass;
class n8n implements n8n_i
{
private const WORKFLOW_READ_ONLY_FIELDS = [
'id',
'active',
'createdAt',
'updatedAt',
'tags',
'shared',
'activeVersion',
];
public n8n_c $config;
public function __construct()
{
$this->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.');
}
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
return $target;
}
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
if ($baseUrl === '') {
throw new Exception('n8n webhook base URL is not configured.');
}
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
}
/**
* @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 . ').';
}
}