diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b7233b8cc7..c577849053 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1162,6 +1162,13 @@ class App { const authQueryParameters = _.get(oauthCredentials, 'authQueryParameters', '') as string; let returnUri = oAuthObj.code.getUri(); + // if scope uses comma, change it as the library always return then with spaces + if ((_.get(oauthCredentials, 'scope') as string).includes(',')) { + const data = querystring.parse(returnUri.split('?')[1] as string); + data.scope = _.get(oauthCredentials, 'scope') as string; + returnUri = `${_.get(oauthCredentials, 'authUrl', '')}?${querystring.stringify(data)}`; + } + if (authQueryParameters) { returnUri += '&' + authQueryParameters; } diff --git a/packages/nodes-base/credentials/MediumApi.credentials.ts b/packages/nodes-base/credentials/MediumApi.credentials.ts new file mode 100644 index 0000000000..7e7fd501f4 --- /dev/null +++ b/packages/nodes-base/credentials/MediumApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class MediumApi implements ICredentialType { + name = 'mediumApi'; + displayName = 'Medium API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/MediumOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MediumOAuth2Api.credentials.ts new file mode 100644 index 0000000000..b335d58b74 --- /dev/null +++ b/packages/nodes-base/credentials/MediumOAuth2Api.credentials.ts @@ -0,0 +1,60 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class MediumOAuth2Api implements ICredentialType { + name = 'mediumOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Medium OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://medium.com/m/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://medium.com/v1/tokens', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'basicProfile,publishPost,listPublications', + }, + { + 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: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Medium/GenericFunctions.ts b/packages/nodes-base/nodes/Medium/GenericFunctions.ts new file mode 100644 index 0000000000..181002836d --- /dev/null +++ b/packages/nodes-base/nodes/Medium/GenericFunctions.ts @@ -0,0 +1,54 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function mediumApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise { // tslint:disable-line:no-any + + const authenticationMethod = this.getNodeParameter('authentication', 0); + + const options: OptionsWithUri = { + method, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Accept-Charset': 'utf-8', + }, + qs: query, + uri: uri || `https://api.medium.com/v1${endpoint}`, + body, + json: true, + }; + + try { + if (authenticationMethod === 'accessToken') { + const credentials = this.getCredentials('mediumApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`; + + return await this.helpers.request!(options); + } + else { + return await this.helpers.requestOAuth2!.call(this, 'mediumOAuth2Api', options); + } + } catch (error) { + if (error.statusCode === 401) { + throw new Error('The Medium credentials are not valid!'); + } + throw error; + } +} diff --git a/packages/nodes-base/nodes/Medium/Medium.node.ts b/packages/nodes-base/nodes/Medium/Medium.node.ts new file mode 100644 index 0000000000..51df09ec4b --- /dev/null +++ b/packages/nodes-base/nodes/Medium/Medium.node.ts @@ -0,0 +1,562 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodePropertyOptions, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + mediumApiRequest, +} from './GenericFunctions'; + +export class Medium implements INodeType { + description: INodeTypeDescription = { + displayName: 'Medium', + name: 'medium', + group: ['output'], + icon: 'file:medium.png', + version: 1, + description: 'Consume Medium API', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + defaults: { + name: 'Medium', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'mediumApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'mediumOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The method of authentication.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Post', + value: 'post', + }, + { + name: 'Publication', + value: 'publication', + }, + ], + default: 'post', + description: 'Resource to operate on.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'post', + ], + }, + }, + options: [ + { + name: 'create', + value: 'create', + description: 'Create a post.', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // post:create + // ---------------------------------- + { + displayName: 'Publication', + name: 'publication', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + }, + }, + default: false, + description: 'Are you posting for a publication?' + }, + { + displayName: 'Publication ID', + name: 'publicationId', + type: 'options', + displayOptions: { + show: { + resource: [ + 'post', + ], + operation: [ + 'create', + ], + publication: [ + true, + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getPublications', + }, + default: '', + description: 'Publication ids', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + placeholder: 'My Open Source Contribution', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + description: 'Title of the post. Max Length : 100 characters', + }, + { + displayName: 'Content Format', + name: 'contentFormat', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + type: 'options', + options: [ + { + name: 'HTML', + value: 'html', + }, + { + name: 'Markdown', + value: 'markdown', + }, + ], + description: 'The format of the content to be posted.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + default: '', + placeholder: 'My open source contribution', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + description: 'The body of the post, in a valid semantic HTML fragment, or Markdown.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'post', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + placeholder: 'open-source,mlh,fellowship', + description: 'Comma-separated strings to be used as tags for post classification. Max allowed tags: 3. Max tag length: 25 characters.', + }, + { + displayName: 'Publish Status', + name: 'publishStatus', + default: 'public', + type: 'options', + options: [ + { + name: 'Public', + value: 'public', + }, + { + name: 'Draft', + value: 'draft', + }, + { + name: 'Unlisted', + value: 'unlisted', + }, + ], + description: 'The status of the post.', + }, + { + displayName: 'Notify Followers', + name: 'notifyFollowers', + type: 'boolean', + default: false, + description: 'Whether to notify followers that the user has published.', + }, + { + displayName: 'License', + name: 'license', + type: 'string', + default: 'all-rights-reserved', + options: [ + { + name: 'all-rights-reserved', + value: 'all-rights-reserved', + }, + { + name: 'cc-40-by', + value: 'cc-40-by', + }, + { + name: 'cc-40-by-sa', + value: 'cc-40-by-sa', + }, + { + name: 'cc-40-by-nd', + value: 'cc-40-by-nd', + }, + { + name: 'cc-40-by-nc', + value: 'cc-40-by-nc', + }, + { + name: 'cc-40-by-nc-nd', + value: 'cc-40-by-nc-nd', + }, + { + name: 'cc-40-by-nc-sa', + value: 'cc-40-by-nc-sa', + }, + { + name: 'cc-40-zero', + value: 'cc-40-zero', + }, + { + name: 'public-domain', + value: 'public-domain', + }, + ], + description: 'License of the post.', + }, + ], + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'publication', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all publications.', + }, + ], + default: 'publication', + description: 'The operation to perform.', + }, + // ---------------------------------- + // publication:getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'publication', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'publication', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 100, + description: 'How many results to return.', + }, + ], + }; + methods = { + loadOptions: { + // Get all the available publications to display them to user so that he can + // select them easily + async getPublications(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + //Get the User Id + const user = await mediumApiRequest.call( + this, + 'GET', + `/me`, + ); + + const userId = user.data.id; + //Get all publications of that user + const publications = await mediumApiRequest.call( + this, + 'GET', + `/users/${userId}/publications`, + ); + const publicationsList = publications.data; + for (const publication of publicationsList) { + const publicationName = publication.name; + const publicationId = publication.id; + returnData.push({ + name: publicationName, + value: publicationId, + }); + } + return returnData; + }, + + }, + }; + + async execute(this: IExecuteFunctions): Promise { + + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + let operation: string; + let resource: string; + + // For POST + let bodyRequest: IDataObject; + // For Query string + let qs: IDataObject; + let responseData; + + for (let i = 0; i < items.length; i++) { + qs = {}; + + resource = this.getNodeParameter('resource', i) as string; + operation = this.getNodeParameter('operation', i) as string; + + if (resource === 'post') { + //https://github.com/Medium/medium-api-docs + if (operation === 'create') { + // ---------------------------------- + // post:create + // ---------------------------------- + + const title = this.getNodeParameter('title', i) as string; + const contentFormat = this.getNodeParameter('contentFormat', i) as string; + const content = this.getNodeParameter('content', i) as string; + bodyRequest = { + tags: [], + title, + contentFormat, + content, + + }; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i + ) as IDataObject; + if (additionalFields.tags) { + const tags = additionalFields.tags as string; + bodyRequest.tags = tags.split(',').map(item => { + return parseInt(item, 10); + }); + } + + + if (additionalFields.publishStatus) { + bodyRequest.publishStatus = additionalFields.publishStatus as string; + } + if (additionalFields.license) { + bodyRequest.license = additionalFields.license as string; + } + if (additionalFields.notifyFollowers) { + bodyRequest.notifyFollowers = additionalFields.notifyFollowers as string; + } + + const underPublication = this.getNodeParameter('publication', i) as boolean; + + // if user wants to publish it under a specific publication + if (underPublication) { + const publicationId = this.getNodeParameter('publicationId', i) as number; + + responseData = await mediumApiRequest.call( + this, + 'POST', + `/publications/${publicationId}/posts`, + bodyRequest, + qs + ); + } + else { + const responseAuthorId = await mediumApiRequest.call( + this, + 'GET', + '/me', + {}, + qs + ); + + const authorId = responseAuthorId.data.id; + responseData = await mediumApiRequest.call( + this, + 'POST', + `/users/${authorId}/posts`, + bodyRequest, + qs + ); + + responseData = responseData.data; + } + } + } + if (resource === 'publication') { + //https://github.com/Medium/medium-api-docs#32-publications + if (operation === 'getAll') { + // ---------------------------------- + // publication:getAll + // ---------------------------------- + + const returnAll = this.getNodeParameter('returnAll', i) as string; + + const user = await mediumApiRequest.call( + this, + 'GET', + `/me`, + ); + + const userId = user.data.id; + //Get all publications of that user + responseData = await mediumApiRequest.call( + this, + 'GET', + `/users/${userId}/publications`, + ); + + responseData = responseData.data; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Medium/medium.png b/packages/nodes-base/nodes/Medium/medium.png new file mode 100644 index 0000000000..7b2e6216fe Binary files /dev/null and b/packages/nodes-base/nodes/Medium/medium.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 23ef2c5ec2..ba4523eaa0 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -99,6 +99,8 @@ "dist/credentials/MattermostApi.credentials.js", "dist/credentials/MauticApi.credentials.js", "dist/credentials/MauticOAuth2Api.credentials.js", + "dist/credentials/MediumApi.credentials.js", + "dist/credentials/MediumOAuth2Api.credentials.js", "dist/credentials/MessageBirdApi.credentials.js", "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", "dist/credentials/MicrosoftOAuth2Api.credentials.js", @@ -261,6 +263,7 @@ "dist/nodes/Mattermost/Mattermost.node.js", "dist/nodes/Mautic/Mautic.node.js", "dist/nodes/Mautic/MauticTrigger.node.js", + "dist/nodes/Medium/Medium.node.js", "dist/nodes/Merge.node.js", "dist/nodes/MessageBird/MessageBird.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js",