diff --git a/packages/nodes-base/credentials/StrapiApi.credentials.ts b/packages/nodes-base/credentials/StrapiApi.credentials.ts new file mode 100644 index 0000000000..395bbc7f9d --- /dev/null +++ b/packages/nodes-base/credentials/StrapiApi.credentials.ts @@ -0,0 +1,29 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class StrapiApi implements ICredentialType { + name = 'strapiApi'; + displayName = 'Strapi API'; + properties = [ + { + displayName: 'Email', + name: 'email', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'URL', + name: 'url', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Strapi/EntryDescription.ts b/packages/nodes-base/nodes/Strapi/EntryDescription.ts new file mode 100644 index 0000000000..293304a929 --- /dev/null +++ b/packages/nodes-base/nodes/Strapi/EntryDescription.ts @@ -0,0 +1,346 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const entryOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'entry', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an entry', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an entry', + }, + { + name: 'Get', + value: 'get', + description: 'Get an entry', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all entries', + }, + { + name: 'Update', + value: 'update', + description: 'Update an entry', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const entryFields = [ + /* -------------------------------------------------------------------------- */ + /* entry:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Content Type', + name: 'contentType', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Name of the content type', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + placeholder: 'id,name,description', + description: 'Comma separated list of the properties which should used as columns for the new rows.', + }, + /* -------------------------------------------------------------------------- */ + /* entry:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Content Type', + name: 'contentType', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'Name of the content type', + }, + { + displayName: 'Entry ID', + name: 'entryId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'The ID of the entry to get.', + }, + /* -------------------------------------------------------------------------- */ + /* entry:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Content Type', + name: 'contentType', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Name of the content type', + }, + { + displayName: 'Entry ID', + name: 'entryId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'get', + ], + }, + }, + description: 'The ID of the entry to get.', + }, + /* -------------------------------------------------------------------------- */ + /* entry:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Content Type', + name: 'contentType', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Name of the content type', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Returns a list of your user contacts.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Publication State', + name: 'publicationState', + type: 'options', + options: [ + { + name: 'Live', + value: 'live', + }, + { + name: 'Preview', + value: 'preview', + }, + ], + default: '', + description: 'Only select entries matching the publication state provided.', + }, + { + displayName: 'Sort Fields', + name: 'sort', + type: 'string', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add Sort Field', + }, + default: '', + placeholder: 'name:asc', + description: `Name of the fields to sort the data by. By default will be sorted ascendingly.
+ To modify that behavior, you have to add the sort direction after the name of sort field preceded by a colon. + For example: name:asc`, + }, + { + displayName: 'Where (JSON)', + name: 'where', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'JSON query to filter the data. Info', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* entry:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Content Type', + name: 'contentType', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'update', + ], + }, + }, + description: 'Name of the content type', + }, + { + displayName: 'Update Key', + name: 'updateKey', + type: 'string', + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'update', + ], + }, + }, + default: 'id', + required: true, + description: 'Name of the property which decides which rows in the database should be updated. Normally that would be "id".', + }, + { + displayName: 'Columns', + name: 'columns', + type: 'string', + displayOptions: { + show: { + resource: [ + 'entry', + ], + operation: [ + 'update', + ], + }, + }, + default: '', + placeholder: 'id,name,description', + description: 'Comma separated list of the properties which should used as columns for the new rows.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Strapi/GenericFunctions.ts b/packages/nodes-base/nodes/Strapi/GenericFunctions.ts new file mode 100644 index 0000000000..6431ad0bd9 --- /dev/null +++ b/packages/nodes-base/nodes/Strapi/GenericFunctions.ts @@ -0,0 +1,106 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function strapiApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('strapiApi') as IDataObject; + + try { + const options: OptionsWithUri = { + headers: { + 'Authorization': `Bearer ${qs.jwt}`, + }, + method, + body, + qs, + uri: uri || `${credentials.url}${resource}`, + json: true, + }; + if (Object.keys(headers).length !== 0) { + options.headers = Object.assign({}, options.headers, headers); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + delete qs.jwt; + + //@ts-ignore + return await this.helpers?.request(options); + } catch (error) { + if (error.response && error.response.body && error.response.body.message) { + + let messages = error.response.body.message; + + if (Array.isArray(error.response.body.message)) { + messages = messages[0].messages.map((e: IDataObject) => e.message).join('|'); + } + // Try to return the error prettier + throw new Error( + `Strapi error response [${error.statusCode}]: ${messages}`, + ); + } + throw error; + } +} + +export async function getToken(this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('strapiApi') as IDataObject; + + const options: OptionsWithUri = { + headers: { + 'content-type': `application/json`, + }, + method: 'POST', + uri: `${credentials.url}/auth/local`, + body: { + identifier: credentials.email, + password: credentials.password, + }, + json: true, + }; + + return this.helpers.request!(options); +} + +export async function strapiApiRequestAllItems(this: IHookFunctions | ILoadOptionsFunctions | IExecuteFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + query._limit = 20; + + query._start = 0; + + do { + responseData = await strapiApiRequest.call(this, method, resource, body, query); + query._start += query._limit; + returnData.push.apply(returnData, responseData); + } while ( + responseData.length !== 0 + ); + + return returnData; +} + +export function validateJSON(json: string | undefined): any { // tslint:disable-line:no-any + let result; + try { + result = JSON.parse(json!); + } catch (exception) { + result = undefined; + } + return result; +} diff --git a/packages/nodes-base/nodes/Strapi/Strapi.node.ts b/packages/nodes-base/nodes/Strapi/Strapi.node.ts new file mode 100644 index 0000000000..3b38ae7aad --- /dev/null +++ b/packages/nodes-base/nodes/Strapi/Strapi.node.ts @@ -0,0 +1,191 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + getToken, + strapiApiRequest, + strapiApiRequestAllItems, + validateJSON, +} from './GenericFunctions'; + +import { + entryFields, + entryOperations, +} from './EntryDescription'; + +export class Strapi implements INodeType { + description: INodeTypeDescription = { + displayName: 'Strapi', + name: 'strapi', + icon: 'file:strapi.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Strapi API.', + defaults: { + name: 'Strapi', + color: '#725ed8', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'strapiApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Entry', + value: 'entry', + }, + ], + default: 'entry', + description: 'The resource to operate on.', + }, + ...entryOperations, + ...entryFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + const { jwt } = await getToken.call(this); + + qs.jwt = jwt; + + if (resource === 'entry') { + if (operation === 'create') { + for (let i = 0; i < length; i++) { + + const body: IDataObject = {}; + + const contentType = this.getNodeParameter('contentType', i) as string; + + const columns = this.getNodeParameter('columns', i) as string; + + const columnList = columns.split(',').map(column => column.trim()); + + for (const key of Object.keys(items[i].json)) { + if (columnList.includes(key)) { + body[key] = items[i].json[key]; + } + } + responseData = await strapiApiRequest.call(this, 'POST', `/${contentType}`, body, qs); + + returnData.push(responseData); + } + } + + if (operation === 'delete') { + for (let i = 0; i < length; i++) { + const contentType = this.getNodeParameter('contentType', i) as string; + + const entryId = this.getNodeParameter('entryId', i) as string; + + responseData = await strapiApiRequest.call(this, 'DELETE', `/${contentType}/${entryId}`, {}, qs); + + returnData.push(responseData); + } + } + + if (operation === 'getAll') { + for (let i = 0; i < length; i++) { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const contentType = this.getNodeParameter('contentType', i) as string; + + const options = this.getNodeParameter('options', i) as IDataObject; + + if (options.sort && (options.sort as string[]).length !== 0) { + const sortFields = options.sort as string[]; + qs._sort = sortFields.join(','); + } + + if (options.where) { + const query = validateJSON(options.where as string); + if (query !== undefined) { + qs._where = query; + } else { + throw new Error('Query must be a valid JSON'); + } + } + + if (options.publicationState) { + qs._publicationState = options.publicationState as string; + } + + if (returnAll) { + responseData = await strapiApiRequestAllItems.call(this, 'GET', `/${contentType}`, {}, qs); + } else { + qs._limit = this.getNodeParameter('limit', i) as number; + + responseData = await strapiApiRequest.call(this, 'GET', `/${contentType}`, {}, qs); + } + returnData.push.apply(returnData, responseData); + } + } + + if (operation === 'get') { + for (let i = 0; i < length; i++) { + + const contentType = this.getNodeParameter('contentType', i) as string; + + const entryId = this.getNodeParameter('entryId', i) as string; + + responseData = await strapiApiRequest.call(this, 'GET', `/${contentType}/${entryId}`, {}, qs); + + returnData.push(responseData); + } + } + + if (operation === 'update') { + for (let i = 0; i < length; i++) { + + const body: IDataObject = {}; + + const contentType = this.getNodeParameter('contentType', i) as string; + + const columns = this.getNodeParameter('columns', i) as string; + + const updateKey = this.getNodeParameter('updateKey', i) as string; + + const columnList = columns.split(',').map(column => column.trim()); + + const entryId = items[i].json[updateKey]; + + for (const key of Object.keys(items[i].json)) { + if (columnList.includes(key)) { + body[key] = items[i].json[key]; + } + } + responseData = await strapiApiRequest.call(this, 'PUT', `/${contentType}/${entryId}`, body, qs); + + returnData.push(responseData); + } + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Strapi/strapi.svg b/packages/nodes-base/nodes/Strapi/strapi.svg new file mode 100644 index 0000000000..bf9f95847a --- /dev/null +++ b/packages/nodes-base/nodes/Strapi/strapi.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 7fe2ff5992..64cbc4de9e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -180,6 +180,7 @@ "dist/credentials/SpotifyOAuth2Api.credentials.js", "dist/credentials/StoryblokContentApi.credentials.js", "dist/credentials/StoryblokManagementApi.credentials.js", + "dist/credentials/StrapiApi.credentials.js", "dist/credentials/SurveyMonkeyApi.credentials.js", "dist/credentials/SurveyMonkeyOAuth2Api.credentials.js", "dist/credentials/TaigaCloudApi.credentials.js", @@ -387,6 +388,7 @@ "dist/nodes/SseTrigger.node.js", "dist/nodes/Start.node.js", "dist/nodes/Storyblok/Storyblok.node.js", + "dist/nodes/Strapi/Strapi.node.js", "dist/nodes/Strava/Strava.node.js", "dist/nodes/Strava/StravaTrigger.node.js", "dist/nodes/Stripe/StripeTrigger.node.js",