Files
OpenNoodl/packages/noodl-runtime/src/nodes/std-library/data/httpnode.js

1007 lines
28 KiB
JavaScript

/**
* 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);
}
});
}
};