Contentful integration

This commit is contained in:
Sven Schmidt 2020-07-09 11:36:28 +02:00
parent 6a3f075612
commit fa0e8c84f5
12 changed files with 632 additions and 0 deletions

View file

@ -0,0 +1,22 @@
import { ICredentialType, NodePropertyTypes } from 'n8n-workflow';
export class ContentfulDeliveryApi implements ICredentialType {
name = 'contentfulDeliveryApi';
displayName = 'Delivery API';
properties = [
{
displayName: 'Space Id',
name: 'space_id',
type: 'string' as NodePropertyTypes,
default: '',
description: 'The id for the Cotentful space.'
},
{
displayName: 'Access Token',
name: 'access_token',
type: 'string' as NodePropertyTypes,
default: '',
description: 'Access token that has access to the space'
}
];
}

View file

@ -0,0 +1,34 @@
import { IExecuteFunctions } from 'n8n-core';
import { OptionsWithUrl } from 'request';
/**
* @param {IExecuteFunctions} that Reference to the system's execute functions
* @param {string} endpoint? Endpoint of api call
* @param {string} environmentId? Id of contentful environment (eg. master, staging, etc.)
* @param {Record<string|number>} qs? Query string, can be used for search parameters
*/
export const contentfulApiRequest = async (
that: IExecuteFunctions,
endpoint?: string,
environmentId?: string,
qs?: Record<string, string | number | undefined>
) => {
const credentials = that.getCredentials('contentfulDeliveryApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
let url = `https://cdn.contentful.com/spaces/${credentials.space_id}`;
if (environmentId) url = `${url}/environments/${environmentId}`;
if (endpoint) url = `${url}${endpoint}`;
qs = qs || {};
qs.access_token = credentials.access_token as string;
const res = await that.helpers.request!({
url,
method: 'GET',
qs
} as OptionsWithUrl);
return JSON.parse(res);
};

View file

@ -0,0 +1,49 @@
import { INodeProperties, INodePropertyOptions } from "n8n-workflow";
export const resource = {
name: "Asset",
value: "asset",
} as INodePropertyOptions;
export const operations = [
{
displayName: "Operation",
name: "operation",
type: "options",
displayOptions: {
show: {
resource: [resource.value],
},
},
options: [
{
name: "Get Assets",
value: "get_assets",
},
{
name: "Get Single Asset",
value: "get_asset",
},
],
default: "get_assets",
description: "The operation to perform.",
},
] as INodeProperties[];
export const fields = [
{
displayName: "Asset Id",
name: "asset_id",
type: "string",
default: "",
placeholder: "",
description: "",
required: true,
displayOptions: {
show: {
resource: [resource.value],
operation: ["get_asset"],
},
},
},
] as INodeProperties[];

View file

@ -0,0 +1,49 @@
import { INodeProperties, INodePropertyOptions } from "n8n-workflow";
export const resource = {
name: "Content Types",
value: "content_type",
} as INodePropertyOptions;
export const operations = [
{
displayName: "Operation",
name: "operation",
type: "options",
displayOptions: {
show: {
resource: [resource.value],
},
},
options: [
{
name: "Get Content types",
value: "get_content_types",
},
{
name: "Get Single Content Type",
value: "get_content_type",
},
],
default: "get_content_types",
description: "The operation to perform.",
},
] as INodeProperties[];
export const fields = [
{
displayName: "Content Type Id",
name: "content_type_id",
type: "string",
default: "",
placeholder: "",
description: "",
required: true,
displayOptions: {
show: {
resource: [resource.value],
operation: ["get_content_type"],
},
},
},
] as INodeProperties[];

View file

@ -0,0 +1,142 @@
import { IExecuteFunctions } from 'n8n-core';
import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, NodePropertyTypes } from 'n8n-workflow';
import { contentfulApiRequest } from './ GenericFunctions';
import resolveResponse from './resolveResponse';
import * as SpaceDescription from './SpaceDescription';
import * as ContentTypeDescription from './ContentTypeDescription';
import * as EntryDescription from './EntryDescription';
import * as AssetDescription from './AssetDescription';
import * as LocaleDescription from './LocaleDescription';
import * as SearchParameterDescription from './SearchParameterDescription';
export class Contentful implements INodeType {
description: INodeTypeDescription = {
displayName: 'Contentful',
name: 'contentful',
icon: 'file:contentful.png',
group: ['input'],
version: 1,
description: "Access data through Contentful's Content Delivery API",
defaults: {
name: 'Contentful',
color: '#2E75D4'
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'contentfulDeliveryApi',
required: true
}
],
properties: [
// Common fields:
{
displayName: 'Environment Id',
name: 'environment_id',
type: 'string' as NodePropertyTypes,
default: 'master',
description:
'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".'
},
// Resources:
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
SpaceDescription.resource,
ContentTypeDescription.resource,
EntryDescription.resource,
AssetDescription.resource,
LocaleDescription.resource
],
default: '',
description: 'The resource to operate on.'
},
// Operations:
...SpaceDescription.operations,
...ContentTypeDescription.operations,
...EntryDescription.operations,
...AssetDescription.operations,
...LocaleDescription.operations,
// Resource specific fields:
...SpaceDescription.fields,
...ContentTypeDescription.fields,
...EntryDescription.fields,
...AssetDescription.fields,
...LocaleDescription.fields,
// Options:
...SearchParameterDescription.fields
]
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const environmentId = this.getNodeParameter('environment_id', 0) as string;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
const items = this.getInputData();
const returnData: IDataObject[] = [];
const qs: Record<string, string | number> = {};
for (let i = 0; i < items.length; i++) {
if (resource === 'space') {
if (operation === 'get_space') {
const res = await contentfulApiRequest(this);
returnData.push(res);
}
} else if (resource === 'content_type') {
if (operation === 'get_content_types') {
const res = await contentfulApiRequest(this, '/content_types', environmentId);
const resolvedData = resolveResponse(res, {});
returnData.push(...resolvedData);
} else if (operation === 'get_content_type') {
const id = this.getNodeParameter('content_type_id', 0) as string;
const res = await contentfulApiRequest(this, `/content_types/${id}`, environmentId);
returnData.push(...res.items);
}
} else if (resource === 'entry') {
if (operation === 'get_entries') {
const shouldResolve = this.getNodeParameter('resolve', 0) as boolean;
if (shouldResolve) qs.include = this.getNodeParameter('include', 0) as number;
const searchParameters = this.getNodeParameter('search_parameters', 0) as IDataObject;
if (searchParameters.parameters && Array.isArray(searchParameters.parameters)) {
searchParameters.parameters.forEach(parameter => {
const { name, value } = parameter as { name: string; value: string };
qs[name] = value;
});
}
const res = await contentfulApiRequest(this, '/entries', environmentId, qs);
const resolvedData = shouldResolve ? resolveResponse(res, {}) : res.items;
returnData.push(...resolvedData);
} else if (operation === 'get_entry') {
const id = this.getNodeParameter('entry_id', 0) as string;
const res = await contentfulApiRequest(this, `/entries/${id}`, environmentId);
returnData.push(res);
}
} else if (resource === 'asset') {
if (operation === 'get_assets') {
const res = await contentfulApiRequest(this, '/assets', environmentId);
returnData.push(...res.items);
} else if (operation === 'get_asset') {
const id = this.getNodeParameter('asset_id', 0) as string;
const res = await contentfulApiRequest(this, `/assets/${id}`, environmentId);
returnData.push(res);
}
} else if (resource === 'locale') {
if (operation === 'get_locales') {
const res = await contentfulApiRequest(this, '/locales', environmentId);
returnData.push(res);
}
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,83 @@
import { INodeProperties, INodePropertyOptions } from 'n8n-workflow';
export const resource = {
name: 'Entry',
value: 'entry'
} as INodePropertyOptions;
export const operations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [resource.value]
}
},
options: [
{
name: 'Get Entries',
value: 'get_entries'
},
{
name: 'Get Single Entry',
value: 'get_entry'
}
],
default: 'get_entries',
description: 'The operation to perform.'
}
] as INodeProperties[];
export const fields = [
{
displayName: 'Resolve',
name: 'resolve',
type: 'boolean',
default: false,
description: 'Linked entries can be automatically resolved in the results if you click activate this feature.',
displayOptions: {
show: {
resource: [resource.value],
operation: ['get_entries']
}
}
},
{
displayName: 'Include',
name: 'include',
type: 'number',
default: 1,
placeholder: '',
description:
"When you have related content (e.g. entries with links to image assets) it's possible to include them in the results. Using the include parameter, you can specify the number of levels of entries to include in the results. A lower number might improve performance.",
typeOptions: {
minValue: 0,
maxValue: 10
},
displayOptions: {
show: {
resource: [resource.value],
operation: ['get_entries'],
resolve: [true]
}
}
},
{
displayName: 'Entry Id',
name: 'entry_id',
type: 'string',
default: '',
placeholder: '',
description: '',
required: true,
displayOptions: {
show: {
resource: [resource.value],
operation: ['get_entry']
}
}
}
] as INodeProperties[];

View file

@ -0,0 +1,29 @@
import { INodeProperties, INodePropertyOptions } from "n8n-workflow";
export const resource = {
name: "Locale",
value: "locale",
} as INodePropertyOptions;
export const operations = [
{
displayName: "Operation",
name: "operation",
type: "options",
displayOptions: {
show: {
resource: [resource.value],
},
},
options: [
{
name: "Get Locales",
value: "get_locales",
},
],
default: "get_locales",
description: "The operation to perform.",
},
] as INodeProperties[];
export const fields = [] as INodeProperties[];

View file

@ -0,0 +1,37 @@
import { INodeProperties } from 'n8n-workflow';
export const fields = [
{
displayName: 'Search Parameters',
name: 'search_parameters',
description: 'You can use a variety of query parameters to search and filter items.',
placeholder: 'Add parameter',
type: 'fixedCollection',
typeOptions: {
multipleValues: true
},
default: {},
options: [
{
displayName: 'Parameters',
name: 'parameters',
values: [
{
displayName: 'Parameter Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the search parameter to set.'
},
{
displayName: 'Parameter Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the search parameter to set.'
}
]
}
]
}
] as INodeProperties[];

View file

@ -0,0 +1,29 @@
import { INodeProperties } from "n8n-workflow";
export const resource = {
name: "Space",
value: "space",
};
export const operations = [
{
displayName: "Operation",
name: "operation",
type: "options",
displayOptions: {
show: {
resource: [resource.value],
},
},
options: [
{
name: "Get Space",
value: "get_space",
},
],
default: "get_space",
description: "The operation to perform.",
},
] as INodeProperties[];
export const fields = [] as INodeProperties[];

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,156 @@
// @ts-nocheck
// Code from https://github.com/contentful/contentful-resolve-response/blob/master/index.js
import { cloneDeep } from 'lodash';
const UNRESOLVED_LINK = {}; // unique object to avoid polyfill bloat using Symbol()
/**
* isLink Function
* Checks if the object has sys.type "Link"
* @param object
*/
const isLink = (object: { sys: { type: string } }) =>
object && object.sys && object.sys.type === 'Link';
/**
* findNormalizableLinkInArray
*
* @param array
* @param predicate
* @return {*}
*/
const findNormalizableLinkInArray = (array, predicate) => {
for (let i = 0, len = array.length; i < len; i++) {
if (predicate(array[i])) {
return array[i];
}
}
return UNRESOLVED_LINK;
};
/**
* getLink Function
*
* @param response
* @param link
* @return {undefined}
*/
const getLink = (allEntries, link) => {
const { linkType: type, id } = link.sys;
const predicate = ({ sys }) => sys.type === type && sys.id === id;
return findNormalizableLinkInArray(allEntries, predicate);
};
/**
* cleanUpLinks Function
* - Removes unresolvable links from Arrays and Objects
*
* @param {Object[]|Object} input
*/
const cleanUpLinks = input => {
if (Array.isArray(input)) {
return input.filter(val => val !== UNRESOLVED_LINK);
}
for (const key in input) {
if (input[key] === UNRESOLVED_LINK) {
delete input[key];
}
}
return input;
};
/**
* walkMutate Function
* @param input
* @param predicate
* @param mutator
* @return {*}
*/
const walkMutate = (input, predicate, mutator, removeUnresolved) => {
if (predicate(input)) {
return mutator(input);
}
if (input && typeof input === 'object') {
for (const key in input) {
if (input.hasOwnProperty(key)) {
input[key] = walkMutate(
input[key],
predicate,
mutator,
removeUnresolved
);
}
}
if (removeUnresolved) {
input = cleanUpLinks(input);
}
}
return input;
};
const normalizeLink = (allEntries, link, removeUnresolved) => {
const resolvedLink = getLink(allEntries, link);
if (resolvedLink === UNRESOLVED_LINK) {
return removeUnresolved ? resolvedLink : link;
}
return resolvedLink;
};
const makeEntryObject = (item, itemEntryPoints) => {
if (!Array.isArray(itemEntryPoints)) {
return item;
}
const entryPoints = Object.keys(item).filter(
ownKey => itemEntryPoints.indexOf(ownKey) !== -1
);
return entryPoints.reduce((entryObj, entryPoint) => {
entryObj[entryPoint] = item[entryPoint];
return entryObj;
}, {});
};
/**
* resolveResponse Function
* Resolves contentful response to normalized form.
* @param {Object} response Contentful response
* @param {Object} options
* @param {Boolean} options.removeUnresolved - Remove unresolved links default:false
* @param {Array<String>} options.itemEntryPoints - Resolve links only in those item properties
* @return {Object}
*/
const resolveResponse = (response, options) => {
options = options || {};
if (!response.items) {
return [];
}
const responseClone = cloneDeep(response);
const allIncludes = Object.keys(responseClone.includes || {}).reduce(
(all, type) => [...all, ...response.includes[type]],
[]
);
const allEntries = [...responseClone.items, ...allIncludes];
allEntries.forEach(item => {
const entryObject = makeEntryObject(item, options.itemEntryPoints);
Object.assign(
item,
walkMutate(
entryObject,
isLink,
link => normalizeLink(allEntries, link, options.removeUnresolved),
options.removeUnresolved
)
);
});
return responseClone.items;
};
export default resolveResponse;

View file

@ -43,6 +43,7 @@
"dist/credentials/ClockifyApi.credentials.js",
"dist/credentials/CockpitApi.credentials.js",
"dist/credentials/CodaApi.credentials.js",
"dist/credentials/ContentfulDeliveryApi.credentials.js",
"dist/credentials/CopperApi.credentials.js",
"dist/credentials/CalendlyApi.credentials.js",
"dist/credentials/DisqusApi.credentials.js",
@ -159,6 +160,7 @@
"dist/nodes/Clockify/ClockifyTrigger.node.js",
"dist/nodes/Cockpit/Cockpit.node.js",
"dist/nodes/Coda/Coda.node.js",
"dist/nodes/Contentful/Contentful.node.js",
"dist/nodes/Copper/CopperTrigger.node.js",
"dist/nodes/Cron.node.js",
"dist/nodes/Crypto.node.js",