From a5b6b3d209e8027ddef43289ddd0948eb0cb2cfd Mon Sep 17 00:00:00 2001 From: Shraddha Shaligram Date: Wed, 5 Aug 2020 00:29:29 -0700 Subject: [PATCH] :sparkles: Add Medium Node (#788) * add medium node * minor fixes * fix publication post * fix according to feedback * additional fixes * fix license description * :zap: Small improvements * :zap: Improvements Co-authored-by: ricardo --- packages/cli/src/Server.ts | 7 + .../credentials/MediumApi.credentials.ts | 17 + .../MediumOAuth2Api.credentials.ts | 60 ++ .../nodes/Medium/GenericFunctions.ts | 54 ++ .../nodes-base/nodes/Medium/Medium.node.ts | 562 ++++++++++++++++++ packages/nodes-base/nodes/Medium/medium.png | Bin 0 -> 3294 bytes packages/nodes-base/package.json | 3 + 7 files changed, 703 insertions(+) create mode 100644 packages/nodes-base/credentials/MediumApi.credentials.ts create mode 100644 packages/nodes-base/credentials/MediumOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Medium/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Medium/Medium.node.ts create mode 100644 packages/nodes-base/nodes/Medium/medium.png diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b7233b8cc..c57784905 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 000000000..7e7fd501f --- /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 000000000..b335d58b7 --- /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 000000000..181002836 --- /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 000000000..51df09ec4 --- /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 0000000000000000000000000000000000000000..7b2e6216feef7934b4a1d7fbe6d33169f93e45e1 GIT binary patch literal 3294 zcmY*b2{@Ep8=lFMeM?f5F+~Y8$U4SYvlBzvw=5aU7&9Ye*H{V@k$uftBYe^5BMcQ; zvhQR`glr*u{G+eG@Bh!ap7Y%2dGF_b&U3EoeXsX{k)akV(>W#p0KlrFt%0O^_^~n2 zQ&-r*2$t%AIHZ;upsbUBfx00$o9nn37yzzPF#~`O$OWJ|rcfsUcn(1K3j+YUK)yd1 z3B2-~a{>T}LIG%hb1bR;cxY3Nn*H0;a zFkN{CNJAvR2qq8+1jjnLz>pf6ztgEZC9o?VkAXp< zzP`Q?-)j&r>>a42f`S56LJBG+B~E3CgV5tAP)Udc^p7?*6@DCr-N2%psg;lQ5t8s< z%>Rr1t^I+7L|oO_wbI04Ac2hN`J6-Lj>XM^ejaM7={m zklb*yY`uyO#ZWDbp@KvXx7GFwx0Xne2D6@yd#5hQC1ku8UnCPBb?Hl3KkX^x<0t|E zc|usX(1=o|0c5=I#v+eeqw@%~np~Y&-tAnmSS>sH=^N+yAY5xSAq~okX>4qimX_9D&Qdz`^Y1S% z?kreYfuL56r@zt!y?-xcw7jesd_{?rc;`}^{7=Ra)3*|dE1hh*x@kHB0*6UpFu2u) zZ!SAl<|Ht}J0mjk-e}c=(24npi3vMrcEb63i=`ll|>W<};A)`XGVs z%n=JGPfr>CD&M!$(+&?GK1}#5Zbj}6r(=Gj!_v@|tt2|@{he{#EJiSnC%vp!U0q%J z1D9sHTnJ)dV6bf;)Mc#QGam&jI1{q*^h-hlPhKLsGP~=xrL)lKQX9Pu^Vbzj42`t{ z;xx50EySMn*>(}fTQHxh>fME~58M(w@nvOf_ncN95qij;VO)A(SXk^nh_o+nh0x{1&vGU0w{rp}hMGy$Fi|Xyf3rf0ON=I-jJGLJ` zw$}MCAnzh@CKT5Q1!PDq@j{4L3h}(MVXf~fj|*VQ-B#^lUy(un#|$}UPrN=0>&ERb z*Ce4!fR6@oYL9brpbC3yhEs9)=LLkpmqKEGpBdfUJmTrvqbF+X>?nZK7dTa`gLkrn zJ8+BT5fPi;^VBB-;q|X(4i0jRf+wV&m%P^G(W^Qcw;vTJllWn7e0W?fU~8!;DT$l2 zXx+Pb;N{DgNRj5J^X?8fuj18U;C&;NC@mwI`@v#zQNjD`?f98yR*PuU&uttE^bza4 zgG*b><+?XNZ!RFdVk`PQRChM}NQ8-$AKF67V#!y|*kION^^9Pva1h0n0F)W~WACVex~QS9Z1=A|IY z!-q_H#(6d8^eOF1G=ufiwEW7!`iQs=qmy+mAJ2J$RaBY7TrGF|P1Y@2iCP8*uim&@ z6{bZ87PJI>arw)9K2GjXBQ7pZYW2;@XSrA`4yC9Fkt5Zg3K^N!)a;APxLJj^0nTET zdWYRa`prB4N&WXm&mB?eCN@#0CkDA%IPYQL#-S7Rqt59mXVv=^Zp#T zx7s3RKBrU~O{pmuug&hlqNhRIord@H?r8M1%#kSf1+ijZ_ZUS`4A*_E?w zu1&JXTzndnwmj&KMKxOZ>Y8kX~r{XzlB zth~*>&frvN-Y$Va3p2OYr+!+vYu4AXHJI;Qg@wg8n6!P1PSNw?Eb`B55d$GXsVM&$ zW@z+D`liIo=VJy`LK^mmg=d4B02@8wmR9KtJz8_?X9xoV_COV4Ys zfXGTyifSvj&YSd>UiT*QfCX)Tj&^2k3b6GTer(G4e@$m13r3`S#>w`(K_d=M%%5K}R=l2|H3>jKafu#9wT2sBOjjM`}|)y02G6 zm71>kF5WgPpdwIJC}l)<)XLzm!+|Ec&cF+xnm&{#^y7s>6(L8;hfO zSiXI$&XxS^*^B(X$zW0A>n!j~?|QfaxeKMGVogEF0JBHqApMz`Y9f(s;oHTndJe_G zPysc0A)#Vdgk3S-$46?|{DDu<(WJoq_}`UP@sAP!(+=cLV^XB|3?}qbPkTzcNWd0< zirRSN+4`B9{!y`8D44t3|OlXUJ^@dMm7Ld4?$G;E*B{Bq|(L^QLtGxr+ zuf%@%9n8L{CPbEFmPB-}ElKJ}A$E}Zu9S8&<#|3IF?^o*)xC@uGd1g2NglaaCx&Fk z&yM-ayd3mMbMq@5G(mwCUODa@IF~!^`=aFsw&}Pl+Vyt|i$@X@i^lxBl2=8|+Gw1W zAFgb4A#guZC^K6TjI+W+g?EQ$xo@|~XZqrHxW_nO^9k`~+;ws)aECO-dgrHf619zc zf-TATv!A>*6*vZXr%ujg(4Bw8wJvIGg&B{-({u4(9Q1b_L_joJxe!m4B))~SXb|K#gjH`FLo Hvk(6dv$*5d literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 23ef2c5ec..ba4523eaa 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",