/** * HTTP Node * * A modern, declarative HTTP node that makes API integration accessible to * no-coders while remaining powerful for developers. This replaces the * script-based REST node for most use cases. * * Features: * - URL with path parameter detection (/users/{userId}) * - Visual headers, query params, and body configuration * - Authentication presets (Bearer, Basic, API Key) * - Response mapping with JSONPath * - cURL import support * - Pagination strategies * * @module noodl-runtime * @since 2.0.0 */ // Note: This file uses CommonJS module format to match the noodl-runtime pattern // DEBUG: Confirm module is loaded console.log('[HTTP Node Module] 📦 httpnode.js MODULE LOADED'); /** * Extract value from object using JSONPath-like syntax * Supports: $.data.users, $.items[0].name, $.meta.pagination.total * * @param {object} obj - The object to extract from * @param {string} path - JSONPath expression starting with $ * @returns {*} The extracted value or undefined */ function extractByPath(obj, path) { if (!path || !path.startsWith('$')) return undefined; if (obj === undefined || obj === null) return undefined; const parts = path.substring(2).split('.').filter(Boolean); let current = obj; for (const part of parts) { if (current === undefined || current === null) return undefined; // Handle array access: items[0] const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/); if (arrayMatch) { current = current[arrayMatch[1]]?.[parseInt(arrayMatch[2])]; } else { current = current[part]; } } return current; } /** * Configure authentication headers/params based on preset type */ const authConfigurators = { none: () => ({}), bearer: (inputs) => ({ headers: inputs.authToken ? { Authorization: `Bearer ${inputs.authToken}` } : {} }), basic: (inputs) => { if (!inputs.authUsername || !inputs.authPassword) return {}; const encoded = typeof btoa !== 'undefined' ? btoa(inputs.authUsername + ':' + inputs.authPassword) : Buffer.from(inputs.authUsername + ':' + inputs.authPassword).toString('base64'); return { headers: { Authorization: `Basic ${encoded}` } }; }, apiKey: (inputs) => { if (!inputs.authApiKeyName || !inputs.authApiKeyValue) return {}; if (inputs.authApiKeyLocation === 'query') { return { queryParams: { [inputs.authApiKeyName]: inputs.authApiKeyValue } }; } return { headers: { [inputs.authApiKeyName]: inputs.authApiKeyValue } }; } }; var HttpNode = { name: 'net.noodl.HTTP', displayNodeName: 'HTTP Request', docs: 'https://docs.noodl.net/nodes/data/http-request', category: 'Data', color: 'data', searchTags: ['http', 'request', 'fetch', 'api', 'rest', 'curl'], initialize: function () { console.log('[HTTP Node] 🚀 INITIALIZE called - node instance created'); this._internal.inputValues = {}; this._internal.outputValues = {}; this._internal.headers = ''; this._internal.queryParams = ''; this._internal.bodyFields = ''; this._internal.responseMapping = ''; this._internal.inspectData = null; }, getInspectInfo() { if (!this._internal.inspectData) { return { type: 'text', value: '[Not executed yet]' }; } return { type: 'value', value: this._internal.inspectData }; }, inputs: { // Static inputs - these don't need to trigger port regeneration url: { type: 'string', displayName: 'URL', group: 'Request', default: '', set: function (value) { this._internal.url = value; } }, fetch: { type: 'signal', displayName: 'Fetch', group: 'Actions', valueChangedToTrue: function () { console.log('[HTTP Node] ⚡ FETCH SIGNAL RECEIVED - valueChangedToTrue triggered!'); this.scheduleFetch(); } }, cancel: { type: 'signal', displayName: 'Cancel', group: 'Actions', valueChangedToTrue: function () { this.cancelFetch(); } } // Note: method, timeout, and config ports are now dynamic (in updatePorts) }, outputs: { response: { type: '*', displayName: 'Response', group: 'Response', getter: function () { return this._internal.response; } }, statusCode: { type: 'number', displayName: 'Status Code', group: 'Response', getter: function () { return this._internal.statusCode; } }, responseHeaders: { type: 'object', displayName: 'Response Headers', group: 'Response', getter: function () { return this._internal.responseHeaders; } }, success: { type: 'signal', displayName: 'Success', group: 'Events' }, failure: { type: 'signal', displayName: 'Failure', group: 'Events' }, canceled: { type: 'signal', displayName: 'Canceled', group: 'Events' }, error: { type: 'string', displayName: 'Error', group: 'Events', getter: function () { return this._internal.error; } } }, prototypeExtensions: { // Store values for dynamic inputs only - static inputs (including signals) // use the base Node.prototype.setInputValue which calls input.set() _storeInputValue: function (name, value) { this._internal.inputValues[name] = value; }, getOutputValue: function (name) { return this._internal.outputValues[name]; }, registerOutputIfNeeded: function (name) { if (this.hasOutput(name)) return; if (name.startsWith('out-')) { this.registerOutput(name, { getter: this.getOutputValue.bind(this, name) }); } }, registerInputIfNeeded: function (name) { if (this.hasInput(name)) return; // Configuration inputs - these set internal state const configSetters = { method: this.setMethod.bind(this), timeout: this.setTimeout.bind(this), headers: this.setHeaders.bind(this), queryParams: this.setQueryParams.bind(this), bodyType: this.setBodyType.bind(this), bodyFields: this.setBodyFields.bind(this), authType: this.setAuthType.bind(this), responseMapping: this.setResponseMapping.bind(this) }; if (configSetters[name]) { return this.registerInput(name, { set: configSetters[name] }); } // Dynamic inputs for path params, headers, query params, body fields, auth, mapping paths // These use _storeInputValue to just store values (no signal trigger needed) const dynamicPrefixes = ['path-', 'header-', 'query-', 'body-', 'auth-', 'mapping-path-']; for (const prefix of dynamicPrefixes) { if (name.startsWith(prefix)) { return this.registerInput(name, { set: this._storeInputValue.bind(this, name) }); } } }, scheduleFetch: function () { console.log('[HTTP Node] scheduleFetch called'); if (this._internal.hasScheduledFetch) { console.log('[HTTP Node] Already scheduled, skipping'); return; } this._internal.hasScheduledFetch = true; console.log('[HTTP Node] Scheduling doFetch after inputs update'); this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this)); }, cancelFetch: function () { if (this._internal.abortController) { this._internal.abortController.abort(); this._internal.abortController = null; } }, buildUrl: function () { let url = this._internal.url || ''; // Replace path parameters: /users/{userId} → /users/123 const pathParams = url.match(/\{([A-Za-z0-9_]+)\}/g) || []; for (const param of pathParams) { const name = param.replace(/[{}]/g, ''); const value = this._internal.inputValues['path-' + name]; if (value !== undefined && value !== null) { url = url.replace(param, encodeURIComponent(String(value))); } } // Add query parameters const queryParams = {}; // From visual config (stringlist format) if (this._internal.queryParams) { const queryList = this._internal.queryParams .split(',') .map((q) => q.trim()) .filter(Boolean); for (const qp of queryList) { const value = this._internal.inputValues['query-' + qp]; if (value !== undefined && value !== null && value !== '') { queryParams[qp] = value; } } } // From auth (API Key in query) const authType = this._internal.authType; if (authType && authConfigurators[authType]) { const authConfig = authConfigurators[authType](this._internal.inputValues); if (authConfig.queryParams) { Object.assign(queryParams, authConfig.queryParams); } } // Append query string const queryString = Object.entries(queryParams) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) .join('&'); if (queryString) { url += (url.includes('?') ? '&' : '?') + queryString; } return url; }, buildHeaders: function () { const headers = {}; // From visual config (stringlist format) if (this._internal.headers) { const headerList = this._internal.headers .split(',') .map((h) => h.trim()) .filter(Boolean); for (const h of headerList) { const value = this._internal.inputValues['header-' + h]; if (value !== undefined && value !== null) { headers[h] = String(value); } } } // From auth const authType = this._internal.authType; if (authType && authConfigurators[authType]) { const authConfig = authConfigurators[authType](this._internal.inputValues); if (authConfig.headers) { Object.assign(headers, authConfig.headers); } } return headers; }, buildBody: function () { const method = this._internal.method || 'GET'; if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { return undefined; } const bodyType = this._internal.bodyType || 'json'; const bodyFieldsStr = this._internal.bodyFields || ''; // Parse stringlist format const bodyFields = bodyFieldsStr .split(',') .map((f) => f.trim()) .filter(Boolean); if (bodyType === 'json') { const body = {}; for (const field of bodyFields) { const value = this._internal.inputValues['body-' + field]; if (value !== undefined) { body[field] = value; } } return Object.keys(body).length > 0 ? JSON.stringify(body) : undefined; } else if (bodyType === 'form') { const formData = new FormData(); for (const field of bodyFields) { const value = this._internal.inputValues['body-' + field]; if (value !== undefined && value !== null) { formData.append(field, value); } } return formData; } else if (bodyType === 'urlencoded') { const params = new URLSearchParams(); for (const field of bodyFields) { const value = this._internal.inputValues['body-' + field]; if (value !== undefined && value !== null) { params.append(field, String(value)); } } return params.toString(); } else if (bodyType === 'raw') { return this._internal.inputValues['body-raw']; } return undefined; }, processResponse: function (response, responseBody) { // Store raw response this._internal.response = responseBody; this._internal.statusCode = response.status; // Extract response headers const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); this._internal.responseHeaders = responseHeaders; // Process response mappings // Output names are in responseMapping (comma-separated) // Path for each output is in inputValues['mapping-path-{name}'] const mappingStr = this._internal.responseMapping || ''; const outputNames = mappingStr .split(',') .map((m) => m.trim()) .filter(Boolean); for (const name of outputNames) { // Get the path from the corresponding input port const path = this._internal.inputValues['mapping-path-' + name] || '$'; const outputName = 'out-' + name; const value = extractByPath(responseBody, path); this.registerOutputIfNeeded(outputName); this._internal.outputValues[outputName] = value; this.flagOutputDirty(outputName); } // Flag standard outputs this.flagOutputDirty('response'); this.flagOutputDirty('statusCode'); this.flagOutputDirty('responseHeaders'); // Update inspect data this._internal.inspectData = { url: this._internal.lastRequestUrl, method: this._internal.method, status: response.status, response: responseBody }; }, doFetch: function () { console.log('[HTTP Node] doFetch executing'); this._internal.hasScheduledFetch = false; const url = this.buildUrl(); const method = this._internal.method || 'GET'; const headers = this.buildHeaders(); const body = this.buildBody(); const timeout = this._internal.timeout || 30000; console.log('[HTTP Node] Request details:', { url, method, headers, body: body ? '(has body)' : '(no body)', timeout }); // Store for inspect this._internal.lastRequestUrl = url; // Validate URL if (!url) { console.log('[HTTP Node] No URL provided, sending failure'); this._internal.error = 'URL is required'; this.flagOutputDirty('error'); this.sendSignalOnOutput('failure'); return; } // Set up abort controller for timeout and cancel const abortController = new AbortController(); this._internal.abortController = abortController; const timeoutId = setTimeout(() => { abortController.abort(); }, timeout); // Set Content-Type for JSON body if not already set const bodyType = this._internal.bodyType || 'json'; if (body && bodyType === 'json' && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } else if (body && bodyType === 'urlencoded' && !headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; } // Perform fetch fetch(url, { method: method, headers: headers, body: body, signal: abortController.signal }) .then((response) => { clearTimeout(timeoutId); this._internal.abortController = null; // Parse response based on content type const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return response.json().then((json) => ({ response, body: json })); } else { return response.text().then((text) => ({ response, body: text })); } }) .then(({ response, body }) => { this.processResponse(response, body); // Send success/failure signal based on status if (response.ok) { this.sendSignalOnOutput('success'); } else { this._internal.error = `HTTP ${response.status}: ${response.statusText}`; this.flagOutputDirty('error'); this.sendSignalOnOutput('failure'); } }) .catch((error) => { clearTimeout(timeoutId); this._internal.abortController = null; if (error.name === 'AbortError') { this.sendSignalOnOutput('canceled'); } else { this._internal.error = error.message || 'Network error'; this.flagOutputDirty('error'); this.sendSignalOnOutput('failure'); } this._internal.inspectData = { url: this._internal.lastRequestUrl, method: method, error: this._internal.error }; }); }, // Configuration setters called from setup function setHeaders: function (value) { this._internal.headers = value || ''; }, setQueryParams: function (value) { this._internal.queryParams = value || ''; }, setBodyType: function (value) { this._internal.bodyType = value; }, setBodyFields: function (value) { this._internal.bodyFields = value || ''; }, setResponseMapping: function (value) { this._internal.responseMapping = value || ''; }, setAuthType: function (value) { this._internal.authType = value; }, setMethod: function (value) { this._internal.method = value || 'GET'; }, setTimeout: function (value) { this._internal.timeout = value || 30000; } } }; /** * Update dynamic ports based on node configuration */ function updatePorts(nodeId, parameters, editorConnection) { const ports = []; // Parse URL for path parameters: /users/{userId} → userId port if (parameters.url) { const pathParams = parameters.url.match(/\{([A-Za-z0-9_]+)\}/g) || []; const uniqueParams = [...new Set(pathParams.map((p) => p.replace(/[{}]/g, '')))]; for (const name of uniqueParams) { ports.push({ name: 'path-' + name, displayName: name, type: 'string', plug: 'input', group: 'Path Parameters' }); } } // Headers configuration - comma-separated list ports.push({ name: 'headers', displayName: 'Headers', type: { name: 'stringlist', allowEditOnly: true }, plug: 'input', group: 'Headers' }); // Generate input ports for each header if (parameters.headers) { const headerList = parameters.headers .split(',') .map((h) => h.trim()) .filter(Boolean); for (const header of headerList) { ports.push({ name: 'header-' + header, displayName: header, type: 'string', plug: 'input', group: 'Headers' }); } } // Query parameters configuration - comma-separated list ports.push({ name: 'queryParams', displayName: 'Query Parameters', type: { name: 'stringlist', allowEditOnly: true }, plug: 'input', group: 'Query Parameters' }); // Generate input ports for each query param if (parameters.queryParams) { const queryList = parameters.queryParams .split(',') .map((q) => q.trim()) .filter(Boolean); for (const param of queryList) { ports.push({ name: 'query-' + param, displayName: param, type: 'string', plug: 'input', group: 'Query Parameters' }); } } // Method selector as dynamic port (so we can track parameter changes properly) ports.push({ name: 'method', displayName: 'Method', type: { name: 'enum', enums: [ { label: 'GET', value: 'GET' }, { label: 'POST', value: 'POST' }, { label: 'PUT', value: 'PUT' }, { label: 'PATCH', value: 'PATCH' }, { label: 'DELETE', value: 'DELETE' }, { label: 'HEAD', value: 'HEAD' }, { label: 'OPTIONS', value: 'OPTIONS' } ], allowEditOnly: true }, default: 'GET', plug: 'input', group: 'Request' }); // Body type selector (only shown for POST/PUT/PATCH) const method = parameters.method || 'GET'; if (['POST', 'PUT', 'PATCH'].includes(method)) { ports.push({ name: 'bodyType', displayName: 'Body Type', type: { name: 'enum', enums: [ { label: 'JSON', value: 'json' }, { label: 'Form Data', value: 'form' }, { label: 'URL Encoded', value: 'urlencoded' }, { label: 'Raw', value: 'raw' } ], allowEditOnly: true }, default: 'json', plug: 'input', group: 'Body' }); // Body fields configuration - comma-separated list const bodyType = parameters.bodyType || 'json'; if (bodyType === 'json' || bodyType === 'form' || bodyType === 'urlencoded') { ports.push({ name: 'bodyFields', displayName: 'Body Fields', type: { name: 'stringlist', allowEditOnly: true }, plug: 'input', group: 'Body' }); // Type options for body fields const _types = [ { label: 'String', value: 'string' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' }, { label: 'Array', value: 'array' }, { label: 'Object', value: 'object' }, { label: 'Any', value: '*' } ]; // Generate type selector and value input ports for each body field if (parameters.bodyFields) { const fieldList = parameters.bodyFields .split(',') .map((f) => f.trim()) .filter(Boolean); for (const field of fieldList) { // Get the selected type for this field (default to string) const fieldType = parameters['body-type-' + field] || 'string'; // Type selector for this field ports.push({ name: 'body-type-' + field, displayName: field + ' Type', type: { name: 'enum', enums: _types, allowEditOnly: true }, default: 'string', plug: 'input', group: 'Body' }); // Value input for this field (type matches selected type) ports.push({ name: 'body-' + field, displayName: field, type: fieldType, plug: 'input', group: 'Body' }); } } } else if (bodyType === 'raw') { // Raw body - use code editor with JSON syntax ports.push({ name: 'body-raw', displayName: 'Body', type: { name: 'string', allowEditOnly: true, codeeditor: 'json' }, plug: 'input', group: 'Body' }); } } // Authentication ports.push({ name: 'authType', displayName: 'Authentication', type: { name: 'enum', enums: [ { label: 'None', value: 'none' }, { label: 'Bearer Token', value: 'bearer' }, { label: 'Basic Auth', value: 'basic' }, { label: 'API Key', value: 'apiKey' } ], allowEditOnly: true }, default: 'none', plug: 'input', group: 'Authentication' }); // Auth-specific inputs const authType = parameters.authType || 'none'; if (authType === 'bearer') { ports.push({ name: 'auth-authToken', displayName: 'Token', type: 'string', plug: 'input', group: 'Authentication' }); } else if (authType === 'basic') { ports.push({ name: 'auth-authUsername', displayName: 'Username', type: 'string', plug: 'input', group: 'Authentication' }); ports.push({ name: 'auth-authPassword', displayName: 'Password', type: 'string', plug: 'input', group: 'Authentication' }); } else if (authType === 'apiKey') { ports.push({ name: 'auth-authApiKeyName', displayName: 'Key Name', type: 'string', plug: 'input', group: 'Authentication' }); ports.push({ name: 'auth-authApiKeyValue', displayName: 'Key Value', type: 'string', plug: 'input', group: 'Authentication' }); ports.push({ name: 'auth-authApiKeyLocation', displayName: 'Add To', type: { name: 'enum', enums: [ { label: 'Header', value: 'header' }, { label: 'Query Parameter', value: 'query' } ] }, default: 'header', plug: 'input', group: 'Authentication' }); } // Timeout setting ports.push({ name: 'timeout', displayName: 'Timeout (ms)', type: 'number', default: 30000, plug: 'input', group: 'Request' }); // Response mapping - add output names, then specify JSONPath for each // User adds output names like: userId, userName, totalCount // For each name, we generate a "Path" input and an output port ports.push({ name: 'responseMapping', displayName: 'Output Fields', type: { name: 'stringlist', allowEditOnly: true }, plug: 'input', group: 'Response Mapping' }); // Signal inputs - MUST be in dynamic ports for editor to show them ports.push({ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' }); ports.push({ name: 'cancel', displayName: 'Cancel', type: 'signal', plug: 'input', group: 'Actions' }); // URL input - also needs to be in dynamic ports ports.push({ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' }); // Generate path input ports and output ports for each response mapping if (parameters.responseMapping && typeof parameters.responseMapping === 'string') { const outputNames = parameters.responseMapping .split(',') .map((m) => m.trim()) .filter(Boolean); for (const name of outputNames) { // Path input port for specifying JSONPath (e.g., $.data.id) ports.push({ name: 'mapping-path-' + name, displayName: name + ' Path', type: 'string', default: '$', plug: 'input', group: 'Response Mapping' }); // Output port for the extracted value ports.push({ name: 'out-' + name, displayName: name, type: '*', plug: 'output', group: 'Response' }); } } // Standard outputs ports.push({ name: 'response', displayName: 'Response', type: '*', plug: 'output', group: 'Response' }); ports.push({ name: 'statusCode', displayName: 'Status Code', type: 'number', plug: 'output', group: 'Response' }); ports.push({ name: 'responseHeaders', displayName: 'Response Headers', type: 'object', plug: 'output', group: 'Response' }); ports.push({ name: 'success', displayName: 'Success', type: 'signal', plug: 'output', group: 'Events' }); ports.push({ name: 'failure', displayName: 'Failure', type: 'signal', plug: 'output', group: 'Events' }); ports.push({ name: 'canceled', displayName: 'Canceled', type: 'signal', plug: 'output', group: 'Events' }); ports.push({ name: 'error', displayName: 'Error', type: 'string', plug: 'output', group: 'Events' }); editorConnection.sendDynamicPorts(nodeId, ports); } module.exports = { node: HttpNode, setup: function (context, graphModel) { if (!context.editorConnection || !context.editorConnection.isRunningLocally()) { return; } function _managePortsForNode(node) { updatePorts(node.id, node.parameters || {}, context.editorConnection); node.on('parameterUpdated', function (event) { // Update ports when configuration changes if ( event.name === 'url' || event.name === 'method' || event.name === 'headers' || event.name === 'queryParams' || event.name === 'bodyType' || event.name === 'bodyFields' || event.name === 'authType' || event.name === 'responseMapping' || event.name.startsWith('body-type-') // Body field type changes ) { updatePorts(node.id, node.parameters, context.editorConnection); } }); } graphModel.on('editorImportComplete', () => { graphModel.on('nodeAdded.net.noodl.HTTP', function (node) { _managePortsForNode(node); }); for (const node of graphModel.getNodesWithType('net.noodl.HTTP')) { _managePortsForNode(node); } }); } };