diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..af224f7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,18 @@ +name: Publish Package +on: + release: + types: [created] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '16.x' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 43f0671..c130036 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,31 @@ -![Banner image](https://user-images.githubusercontent.com/10284570/173569848-c624317f-42b1-45a6-ab09-f0ea3c247648.png) +# n8n-nodes-baserow-trigger -# n8n-nodes-starter +This is an n8n community node. It lets you use Baserow to trigger workflows in n8n. -This repo contains example nodes to help you get started building your own custom integrations for [n8n](n8n.io). It includes the node linter and other dependencies. +[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. -To make your custom node available to the community, you must create it as an npm package, and [submit it to the npm registry](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry). +[Installation](#installation) +[Credentials](#credentials) +[Compatibility](#compatibility) +[Resources](#resources) +[Version history](#version-history) -## Prerequisites +## Installation -You need the following installed on your development machine: +Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation. -* [git](https://git-scm.com/downloads) -* Node.js and npm. Minimum version Node 16. You can find instructions on how to install both using nvm (Node Version Manager) for Linux, Mac, and WSL [here](https://github.com/nvm-sh/nvm). For Windows users, refer to Microsoft's guide to [Install NodeJS on Windows](https://docs.microsoft.com/en-us/windows/dev-environment/javascript/nodejs-on-windows). -* Install n8n with: - ``` - npm install n8n -g - ``` -* Recommended: follow n8n's guide to [set up your development environment](https://docs.n8n.io/integrations/creating-nodes/build/node-development-environment/). +## Credentials +This node uses the existing n8n Baserow credentials. -## Using this starter +## Compatibility -These are the basic steps for working with the starter. For detailed guidance on creating and publishing nodes, refer to the [documentation](https://docs.n8n.io/integrations/creating-nodes/). +This node has been tested with n8n 0.208.1 and both Self hosted and Baserow hosted instances. -1. [Generate a new repository](https://github.com/n8n-io/n8n-nodes-starter/generate) from this template repository. -2. Clone your new repo: - ``` - git clone https://github.com//.git - ``` -3. Run `npm i` to install dependencies. -4. Open the project in your editor. -5. Browse the examples in `/nodes` and `/credentials`. Modify the examples, or replace them with your own nodes. -6. Update the `package.json` to match your details. -7. Run `npm run lint` to check for errors or `npm run lintfix` to automatically fix errors when possible. -8. Test your node locally. Refer to [Run your node locally](https://docs.n8n.io/integrations/creating-nodes/test/run-node-locally/) for guidance. -9. Replace this README with documentation for your node. Use the [README_TEMPLATE](README_TEMPLATE.md) to get started. -10. Update the LICENSE file to use your details. -11. [Publish](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) your package to npm. +## Resources -## More information +* [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) -Refer to our [documentation on creating nodes](https://docs.n8n.io/integrations/creating-nodes/) for detailed information on building your own nodes. +## Version history -## License - -[MIT](https://github.com/n8n-io/n8n-nodes-starter/blob/master/LICENSE.md) +0.1.0 - Initial Release diff --git a/credentials/ExampleCredentialsApi.credentials.ts b/credentials/ExampleCredentialsApi.credentials.ts deleted file mode 100644 index 3d7f059..0000000 --- a/credentials/ExampleCredentialsApi.credentials.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - IAuthenticateGeneric, - ICredentialTestRequest, - ICredentialType, - INodeProperties, -} from 'n8n-workflow'; - -export class ExampleCredentialsApi implements ICredentialType { - name = 'exampleCredentialsApi'; - displayName = 'Example Credentials API'; - properties: INodeProperties[] = [ - // The credentials to get from user and save encrypted. - // Properties can be defined exactly in the same way - // as node properties. - { - displayName: 'User Name', - name: 'username', - type: 'string', - default: '', - }, - { - displayName: 'Password', - name: 'password', - type: 'string', - typeOptions: { - password: true, - }, - default: '', - }, - ]; - - // This credential is currently not used by any node directly - // but the HTTP Request node can use it to make requests. - // The credential is also testable due to the `test` property below - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - auth: { - username: '={{ $credentials.username }}', - password: '={{ $credentials.password }}', - }, - qs: { - // Send this as part of the query string - n8n: 'rocks', - }, - }, - }; - - // The block below tells how this credential can be tested - test: ICredentialTestRequest = { - request: { - baseURL: 'https://example.com/', - url: '', - }, - }; -} diff --git a/credentials/HttpBinApi.credentials.ts b/credentials/HttpBinApi.credentials.ts deleted file mode 100644 index 7ea34f2..0000000 --- a/credentials/HttpBinApi.credentials.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - IAuthenticateGeneric, - ICredentialTestRequest, - ICredentialType, - INodeProperties, -} from 'n8n-workflow'; - -export class HttpBinApi implements ICredentialType { - name = 'httpbinApi'; - displayName = 'HttpBin API'; - documentationUrl = ''; - properties: INodeProperties[] = [ - { - displayName: 'Token', - name: 'token', - type: 'string', - default: '', - }, - { - displayName: 'Domain', - name: 'domain', - type: 'string', - default: 'https://httpbin.org', - }, - ]; - - // This allows the credential to be used by other parts of n8n - // stating how this credential is injected as part of the request - // An example is the Http Request node that can make generic calls - // reusing this credential - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - Authorization: '={{"Bearer " + $credentials.token}}', - }, - }, - }; - - // The block below tells how this credential can be tested - test: ICredentialTestRequest = { - request: { - baseURL: '={{$credentials?.domain}}', - url: '/bearer', - }, - }; -} diff --git a/nodes/BaserowTrigger/BaserowTrigger.node.json b/nodes/BaserowTrigger/BaserowTrigger.node.json new file mode 100644 index 0000000..28b1f14 --- /dev/null +++ b/nodes/BaserowTrigger/BaserowTrigger.node.json @@ -0,0 +1,19 @@ +{ + "node": "n8n-nodes-base.baserowtrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/baserow" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.baserow/" + } + ], + "generic": [] + } +} diff --git a/nodes/BaserowTrigger/BaserowTrigger.node.ts b/nodes/BaserowTrigger/BaserowTrigger.node.ts new file mode 100644 index 0000000..648198d --- /dev/null +++ b/nodes/BaserowTrigger/BaserowTrigger.node.ts @@ -0,0 +1,225 @@ +import { IHookFunctions, IWebhookFunctions } from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { baserowApiRequest, toOptions } from './GenericFunctions'; +import { LoadedResource } from './types'; + +export class BaserowTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Baserow Trigger', + name: 'baserowTrigger', + icon: 'file:baserow.svg', + group: ['trigger'], + version: 1, + subtitle: + '={{$parameter["events"].join(", ")}}', + description: 'Starts the workflow when Baserow events occur', + defaults: { + name: 'Baserow Trigger', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'baserowApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Database Name or ID', + name: 'databaseId', + type: 'options', + default: '', + required: true, + description: + 'Database to operate on. Choose from the list, or specify an ID using an expression.', + typeOptions: { + loadOptionsMethod: 'getDatabaseIds', + }, + }, + { + displayName: 'Table Name or ID', + name: 'tableId', + type: 'options', + default: '', + required: true, + description: + 'Table to operate on. Choose from the list, or specify an ID using an expression.', + typeOptions: { + loadOptionsDependsOn: ['databaseId'], + loadOptionsMethod: 'getTableIds', + }, + }, + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Rows Created', + value: 'rows.created', + }, + { + name: 'Rows Deleted', + value: 'rows.deleted', + }, + { + name: 'Rows Updated', + value: 'rows.updated', + }, + ], + required: true, + default: [], + description: 'The events to listen to', + }, + ], + }; + + methods = { + loadOptions: { + async getDatabaseIds(this: ILoadOptionsFunctions) { + const endpoint = '/api/applications/'; + const databases = (await baserowApiRequest.call( + this, + 'GET', + endpoint, + )) as LoadedResource[]; + return toOptions(databases); + }, + + async getTableIds(this: ILoadOptionsFunctions) { + const databaseId = this.getNodeParameter('databaseId', 0) as string; + const endpoint = `/api/database/tables/database/${databaseId}/`; + const tables = (await baserowApiRequest.call( + this, + 'GET', + endpoint, + )) as LoadedResource[]; + return toOptions(tables); + }, + }, + } + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId === undefined) { + return false; + } + try { + await baserowApiRequest.call(this, 'GET', `/api/database/webhooks/${webhookData.webhookId}/`); + } catch (error) { + if (error.response.status === 404) { + delete webhookData.webhookId; + delete webhookData.webhookEvents; + return false; + } + throw error; + } + return true; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + if (webhookUrl.includes('//localhost')) { + throw new NodeOperationError( + this.getNode(), + 'The Webhook can not work on "localhost". Please, either setup n8n on a custom domain or start with "--tunnel"!', + ); + } + + const tableId = this.getNodeParameter('tableId') as string; + const events = this.getNodeParameter('events', []); + const endpoint = `/api/database/webhooks/table/${tableId}/`; + + const body = { + "url": webhookUrl, + "include_all_events": false, + events, + "request_method": "POST", + "name": `${this.getWorkflow().name}`, + "use_user_field_names": true + } + + const webhookData = this.getWorkflowStaticData('node'); + + let responseData; + try { + responseData = await baserowApiRequest.call(this, 'POST', endpoint, body); + } catch (error) { + throw error; + } + + if (responseData.id === undefined || responseData.active !== true) { + throw new NodeApiError(this.getNode(), responseData, { + message: 'Baserow webhook creation response did not contain the expected data.', + }); + } + + webhookData.webhookId = responseData.id as string; + webhookData.webhookEvents = responseData.events as string[]; + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `/api/database/webhooks/${webhookData.webhookId}/`; + const body = {}; + try { + await baserowApiRequest.call(this, 'DELETE', endpoint, body); + } catch (error) { + if (error.response.status !== 404) { + return false; + } + } + delete webhookData.webhookId; + delete webhookData.webhookEvents; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData(); + if (bodyData.hook_id !== undefined && bodyData.action === undefined) { + return { + webhookResponse: 'OK', + }; + } + + const returnData: IDataObject[] = []; + + returnData.push({ + body: bodyData, + }); + + return { + workflowData: [this.helpers.returnJsonArray(bodyData)], + }; + } +} diff --git a/nodes/BaserowTrigger/GenericFunctions.ts b/nodes/BaserowTrigger/GenericFunctions.ts new file mode 100644 index 0000000..3edc956 --- /dev/null +++ b/nodes/BaserowTrigger/GenericFunctions.ts @@ -0,0 +1,155 @@ +import { IExecuteFunctions } from 'n8n-core'; + +import { OptionsWithUri } from 'request'; + +import { IDataObject, IHookFunctions, ILoadOptionsFunctions, NodeApiError } from 'n8n-workflow'; + +import { Accumulator, BaserowCredentials, LoadedResource } from './types'; + +/** + * Make a request to Baserow API. + */ +export async function baserowApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions, + method: string, + endpoint: string, + //jwtToken: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const credentials = (await this.getCredentials('baserowApi')) as BaserowCredentials; + const jwtToken = await getJwtToken.call(this, credentials); + + const options: OptionsWithUri = { + headers: { + Authorization: `JWT ${jwtToken}`, + }, + method, + body, + qs, + uri: `${credentials.host}${endpoint}`, + json: true, + }; + + if (Object.keys(qs).length === 0) { + delete options.qs; + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +/** + * Get a JWT token based on Baserow account username and password. + */ +export async function getJwtToken( + this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions, + { username, password, host }: BaserowCredentials, +) { + const options: OptionsWithUri = { + method: 'POST', + body: { + username, + password, + }, + uri: `${host}/api/user/token-auth/`, + json: true, + }; + + try { + const { token } = (await this.helpers.request!(options)) as { token: string }; + return token; + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function getFieldNamesAndIds( + this: IExecuteFunctions, + tableId: string, + //jwtToken: string, +) { + const endpoint = `/api/database/fields/table/${tableId}/`; + const response = (await baserowApiRequest.call( + this, + 'GET', + endpoint, + //jwtToken, + )) as LoadedResource[]; + + return { + names: response.map((field) => field.name), + ids: response.map((field) => `field_${field.id}`), + }; +} + +export const toOptions = (items: LoadedResource[]) => + items.map(({ name, id }) => ({ name, value: id })); + +/** + * Responsible for mapping field IDs `field_n` to names and vice versa. + */ +export class TableFieldMapper { + nameToIdMapping: Record = {}; + + idToNameMapping: Record = {}; + + mapIds = true; + + async getTableFields( + this: IExecuteFunctions, + table: string, + //jwtToken: string, + ): Promise { + const endpoint = `/api/database/fields/table/${table}/`; + return baserowApiRequest.call(this, 'GET', endpoint); + } + + createMappings(tableFields: LoadedResource[]) { + this.nameToIdMapping = this.createNameToIdMapping(tableFields); + this.idToNameMapping = this.createIdToNameMapping(tableFields); + } + + private createIdToNameMapping(responseData: LoadedResource[]) { + return responseData.reduce((acc, cur) => { + acc[`field_${cur.id}`] = cur.name; + return acc; + }, {}); + } + + private createNameToIdMapping(responseData: LoadedResource[]) { + return responseData.reduce((acc, cur) => { + acc[cur.name] = `field_${cur.id}`; + return acc; + }, {}); + } + + setField(field: string) { + return this.mapIds ? field : this.nameToIdMapping[field] ?? field; + } + + idsToNames(obj: Record) { + Object.entries(obj).forEach(([key, value]) => { + if (this.idToNameMapping[key] !== undefined) { + delete obj[key]; + obj[this.idToNameMapping[key]] = value; + } + }); + } + + namesToIds(obj: Record) { + Object.entries(obj).forEach(([key, value]) => { + if (this.nameToIdMapping[key] !== undefined) { + delete obj[key]; + obj[this.nameToIdMapping[key]] = value; + } + }); + } +} diff --git a/nodes/BaserowTrigger/baserow.svg b/nodes/BaserowTrigger/baserow.svg new file mode 100644 index 0000000..a184ad4 --- /dev/null +++ b/nodes/BaserowTrigger/baserow.svg @@ -0,0 +1,143 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + +