diff --git a/packages/nodes-base/credentials/NetlifyApi.credentials.ts b/packages/nodes-base/credentials/NetlifyApi.credentials.ts new file mode 100644 index 0000000000..939a2cd307 --- /dev/null +++ b/packages/nodes-base/credentials/NetlifyApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class NetlifyApi implements ICredentialType { + name = 'netlifyApi'; + displayName = 'Netlify API'; + documentationUrl = 'netlify'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} \ No newline at end of file diff --git a/packages/nodes-base/credentials/NetlifyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/NetlifyOAuth2Api.credentials.ts new file mode 100644 index 0000000000..706dbc9a03 --- /dev/null +++ b/packages/nodes-base/credentials/NetlifyOAuth2Api.credentials.ts @@ -0,0 +1,60 @@ +// import { +// ICredentialType, +// NodePropertyTypes, +// } from 'n8n-workflow'; + +// export class NetlifyOAuth2Api implements ICredentialType { +// name = 'netlifyOAuth2Api'; +// extends = [ +// 'oAuth2Api', +// ]; +// displayName = 'Netlify OAuth2 API'; +// documentationUrl = 'netlify'; +// properties = [ +// { +// displayName: 'Authorization URL', +// name: 'authUrl', +// type: 'hidden' as NodePropertyTypes, +// default: 'https://app.netlify.com/authorize', +// required: true, +// }, +// { +// displayName: 'Client ID', +// name: 'clientId', +// type: 'string' as NodePropertyTypes, +// default: '', +// required: true, +// }, +// { +// displayName: 'Client Secret', +// name: 'clientSecret', +// type: 'string' as NodePropertyTypes, +// default: '', +// required: true, +// }, +// { +// displayName: 'Authentication', +// name: 'authentication', +// type: 'hidden' as NodePropertyTypes, +// default: 'body', +// }, +// { +// displayName: 'Access Token URL', +// name: 'accessTokenUrl', +// type: 'hidden' as NodePropertyTypes, +// default: 'https://api.netlify.com/api/v1/oauth/tickets', +// }, +// { +// displayName: 'Scope', +// name: 'scope', +// type: 'hidden' as NodePropertyTypes, +// default: '', +// }, +// { +// displayName: 'Auth URI Query Parameters', +// name: 'authQueryParameters', +// type: 'hidden' as NodePropertyTypes, +// default: '', +// } +// ]; +// } diff --git a/packages/nodes-base/nodes/Netlify/DeployDescription.ts b/packages/nodes-base/nodes/Netlify/DeployDescription.ts new file mode 100644 index 0000000000..152b8b6826 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/DeployDescription.ts @@ -0,0 +1,158 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const deployOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'deploy', + ], + }, + }, + options: [ + { + name: 'Cancel', + value: 'cancel', + description: 'Cancel a deployment', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new deployment', + }, + { + name: 'Get', + value: 'get', + description: 'Get a deployment', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all deployments', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const deployFields = [ + { + displayName: 'Site ID', + name: 'siteId', + required: true, + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + description: 'Enter the Site ID', + displayOptions:{ + show: { + resource: [ + 'deploy', + ], + operation: [ + 'get', + 'create', + 'getAll', + ], + }, + }, + }, + { + displayName: 'Deploy ID', + name: 'deployId', + required: true, + type: 'string', + displayOptions:{ + show: { + resource: [ + 'deploy', + ], + operation: [ + 'get', + 'cancel', + ], + }, + }, + }, + // ----- Get All Deploys ------ // + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'deploy', + ], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'deploy', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 50, + description: 'How many results to return', + }, + // ---- Create Site Deploy ---- // + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Fields', + default: {}, + displayOptions: { + show: { + resource: [ + 'deploy', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Netlify/GenericFunctions.ts b/packages/nodes-base/nodes/Netlify/GenericFunctions.ts new file mode 100644 index 0000000000..bc748b6307 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/GenericFunctions.ts @@ -0,0 +1,70 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, NodeApiError, NodeOperationError, +} from 'n8n-workflow'; + +export async function netlifyApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const options: OptionsWithUri = { + method, + headers: { + 'Content-Type': 'application/json', + }, + qs: query, + body, + uri: uri || `https://api.netlify.com/api/v1${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (Object.keys(option)) { + Object.assign(options, option); + } + console.log(options); + + try { + const credentials = await this.getCredentials('netlifyApi'); + + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function netlifyRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + query.page = 0; + query.per_page = 100; + + do { + responseData = await netlifyApiRequest.call(this, method, endpoint, body, query, undefined, { resolveWithFullResponse: true }); + query.page++; + returnData.push.apply(returnData, responseData.body); + } while ( + responseData.headers.link.includes('next') + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Netlify/Netlify.node.ts b/packages/nodes-base/nodes/Netlify/Netlify.node.ts new file mode 100644 index 0000000000..4cb6c14b65 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/Netlify.node.ts @@ -0,0 +1,184 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + netlifyApiRequest, + netlifyRequestAllItems, +} from './GenericFunctions'; + +import { + deployFields, + deployOperations +} from './DeployDescription'; + +import { + siteFields, + siteOperations +} from './SiteDescription'; + +export class Netlify implements INodeType { + description: INodeTypeDescription = { + displayName: 'Netlify', + name: 'netlify', + icon: 'file:netlify.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Netlify API', + defaults: { + name: 'Netlify', + color: '#1A82e2', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'netlifyApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Deploy', + value: 'deploy', + }, + { + name: 'Site', + value: 'site', + }, + ], + default: 'deploy', + description: 'Resource to consume', + required: true, + }, + ...deployOperations, + ...deployFields, + ...siteOperations, + ...siteFields, + ], + }; + + methods = { + loadOptions: { + async getSites(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const sites = await netlifyApiRequest.call( + this, + 'GET', + '/sites', + ); + for (const site of sites) { + returnData.push({ + name: site.name, + value: site.site_id, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const length = items.length as unknown as number; + let responseData; + const returnData: IDataObject[] = []; + const qs: IDataObject = {}; + const body: IDataObject = {}; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + try { + if (resource === 'deploy') { + + if (operation === 'cancel') { + const deployId = this.getNodeParameter('deployId', i); + responseData = await netlifyApiRequest.call(this, 'POST', `/deploys/${deployId}/cancel`, body, qs); + } + + if (operation === 'create') { + const siteId = this.getNodeParameter('siteId', i); + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + Object.assign(body, additionalFields); + + if (body.title) { + qs.title = body.title; + delete body.title; + } + + responseData = await netlifyApiRequest.call(this, 'POST', `/sites/${siteId}/deploys`, body, qs); + } + + if (operation === 'get') { + const siteId = this.getNodeParameter('siteId', i); + const deployId = this.getNodeParameter('deployId', i); + responseData = await netlifyApiRequest.call(this, 'GET', `/sites/${siteId}/deploys/${deployId}`, body, qs); + } + + if (operation === 'getAll') { + const siteId = this.getNodeParameter('siteId', i); + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === true) { + responseData = await netlifyRequestAllItems.call(this, 'GET', `/sites/${siteId}/deploys`); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await netlifyApiRequest.call(this, 'GET', `/sites/${siteId}/deploys`, {}, { per_page: limit }); + } + } + } + if (resource === 'site') { + + if (operation === 'delete') { + const siteId = this.getNodeParameter('siteId', i); + responseData = await netlifyApiRequest.call(this, 'DELETE', `/sites/${siteId}`); + } + + if (operation === 'get') { + const siteId = this.getNodeParameter('siteId', i); + responseData = await netlifyApiRequest.call(this, 'GET', `/sites/${siteId}`); + } + + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === true) { + responseData = await netlifyRequestAllItems.call(this, 'GET', `/sites`, {}, { filter: 'all' }); + } else { + const limit = this.getNodeParameter('limit', i) as number; + responseData = await netlifyApiRequest.call(this, 'GET', `/sites`, {}, { filter: 'all', per_page: limit }); + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts new file mode 100644 index 0000000000..3a1ae38e6d --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.ts @@ -0,0 +1,233 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + + +import { + netlifyApiRequest, +} from './GenericFunctions'; + +import { + snakeCase, +} from 'change-case'; + +export class NetlifyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Netlify Trigger', + name: 'netlifyTrigger', + icon: 'file:netlify.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["event"]}}', + description: 'Handle netlify events via webhooks', + defaults: { + name: 'Netlify Trigger', + color: '#6ad7b9', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'netlifyApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Site Name/ID', + name: 'siteId', + required: true, + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + description: 'Select the Site ID', + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + default: '', + options: [ + { + name: 'Deploy Building', + value: 'deployBuilding', + }, + { + name: 'Deploy Failed', + value: 'deployFailed', + }, + { + name: 'Deploy Created', + value: 'deployCreated', + }, + { + name: 'Form Submitted', + value: 'submissionCreated', + }, + ], + }, + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + required: true, + displayOptions: { + show: { + event: [ + 'submissionCreated', + ], + }, + }, + default: '', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + description: 'Select a form', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + event: [ + 'submissionCreated', + ], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + ], + }; + + // @ts-ignore + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const qs: IDataObject = {}; + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const event = this.getNodeParameter('event') as string; + qs.site_id = this.getNodeParameter('siteId') as string; + const webhooks = await netlifyApiRequest.call(this, 'GET', '/hooks', {}, qs); + for (const webhook of webhooks) { + if (webhook.type === 'url') { + if (webhook.data.url === webhookUrl && webhook.event === snakeCase(event)) { + webhookData.webhookId = webhook.id; + return true; + } + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + //TODO - implement missing events + // alL posible events can be found doing a GET /hooks/types + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const event = this.getNodeParameter('event') as string; + const body: IDataObject = { + event: snakeCase(event), + data: { + url: webhookUrl, + }, + site_id: this.getNodeParameter('siteId') as string, + }; + const formId = this.getNodeParameter('formId', '*') as string; + if (event === 'submissionCreated' && formId !== '*') { + body.form_id = this.getNodeParameter('formId') as string; + } + const webhook = await netlifyApiRequest.call(this, 'POST', '/hooks', body); + webhookData.webhookId = webhook.id; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + try { + await netlifyApiRequest.call(this, 'DELETE', `/hooks/${webhookData.webhookId}`); + } catch (error) { + return false; + } + delete webhookData.webhookId; + return true; + }, + }, + }; + + methods = { + loadOptions: { + async getSites(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const sites = await netlifyApiRequest.call( + this, + 'GET', + '/sites', + ); + for (const site of sites) { + returnData.push({ + name: site.name, + value: site.site_id, + }); + } + return returnData; + }, + + async getForms(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const siteId = this.getNodeParameter('siteId'); + const forms = await netlifyApiRequest.call( + this, + 'GET', + `/sites/${siteId}/forms`, + ); + for (const form of forms) { + returnData.push({ + name: form.name, + value: form.id, + }); + } + returnData.unshift({ name: '[All Forms]', value: '*' }); + return returnData; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + const simple = this.getNodeParameter('simple', false) as boolean; + const event = this.getNodeParameter('event') as string; + let response = req.body; + + if (simple === true && event === 'submissionCreated') { + response = response.data; + } + + return { + workflowData: [ + this.helpers.returnJsonArray(response), + ], + }; + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Netlify/SiteDescription.ts b/packages/nodes-base/nodes/Netlify/SiteDescription.ts new file mode 100644 index 0000000000..c62fa69a7d --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/SiteDescription.ts @@ -0,0 +1,98 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const siteOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'site', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete a site', + }, + { + name: 'Get', + value: 'get', + description: 'Get a site', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Returns all sites', + }, + ], + default: 'delete', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const siteFields = [ + { + displayName: 'Site ID', + name: 'siteId', + required: true, + type: 'string', + displayOptions: { + show: { + resource: [ + 'site', + ], + operation: [ + 'get', + 'delete', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'site', + ], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'site', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 50, + description: 'How many results to return', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Netlify/netlify.svg b/packages/nodes-base/nodes/Netlify/netlify.svg new file mode 100644 index 0000000000..5fb327b9d1 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/netlify.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 8bccc1d8eb..eb95afa8d8 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -188,6 +188,7 @@ "dist/credentials/Msg91Api.credentials.js", "dist/credentials/MySql.credentials.js", "dist/credentials/NasaApi.credentials.js", + "dist/credentials/NetlifyApi.credentials.js", "dist/credentials/NextCloudApi.credentials.js", "dist/credentials/NextCloudOAuth2Api.credentials.js", "dist/credentials/NocoDb.credentials.js", @@ -494,6 +495,8 @@ "dist/nodes/MySql/MySql.node.js", "dist/nodes/N8nTrigger.node.js", "dist/nodes/Nasa/Nasa.node.js", + "dist/nodes/Netlify/Netlify.node.js", + "dist/nodes/Netlify/NetlifyTrigger.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NoOp.node.js", "dist/nodes/NocoDB/NocoDB.node.js",