From 6955109cbd624392207726c4d8cffb02041616bc Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 2 Dec 2019 16:21:55 -0500 Subject: [PATCH] :sparkles: Shopify trigger done --- packages/cli/src/Server.ts | 5 +- .../credentials/ShopifyApi.credentials.ts | 38 ++ .../nodes/Shopify/GenericFunctions.ts | 42 ++ .../nodes/Shopify/ShopifyTrigger.node.ts | 391 ++++++++++++++++++ packages/nodes-base/nodes/Shopify/shopify.png | Bin 0 -> 6495 bytes packages/nodes-base/package.json | 4 +- 6 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/credentials/ShopifyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Shopify/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Shopify/shopify.png diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 3a85221388..dde0e729b5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -230,7 +230,10 @@ class App { }); // Support application/json type post data - this.app.use(bodyParser.json({ limit: "16mb" })); + this.app.use(bodyParser.json({ limit: "16mb", verify: (req, res, buf) => { + // @ts-ignore + req.rawBody = buf; + }})); // Make sure that Vue history mode works properly this.app.use(history({ diff --git a/packages/nodes-base/credentials/ShopifyApi.credentials.ts b/packages/nodes-base/credentials/ShopifyApi.credentials.ts new file mode 100644 index 0000000000..ce39d6410d --- /dev/null +++ b/packages/nodes-base/credentials/ShopifyApi.credentials.ts @@ -0,0 +1,38 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ShopifyApi implements ICredentialType { + name = 'shopifyApi'; + displayName = 'Shopify API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Shop Name', + name: 'shopName', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Shared Secret', + name: 'sharedSecret', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Shopify/GenericFunctions.ts b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts new file mode 100644 index 0000000000..728f439f82 --- /dev/null +++ b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts @@ -0,0 +1,42 @@ +import { OptionsWithUri } from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, + BINARY_ENCODING +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('shopifyApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + const headerWithAuthentication = Object.assign({}, + { Authorization: ` Basic ${Buffer.from(`${credentials.apiKey}:${credentials.password}`).toString(BINARY_ENCODING)}` }); + + const options: OptionsWithUri = { + headers: headerWithAuthentication, + method, + qs: query, + uri: uri || `https://${credentials.shopName}.myshopify.com/admin/api/2019-10${resource}`, + body, + json: true + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response.body.message || error.response.body.Message; + + if (errorMessage !== undefined) { + throw errorMessage; + } + throw error.response.body; + } +} diff --git a/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts new file mode 100644 index 0000000000..a2eaf6a4d0 --- /dev/null +++ b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts @@ -0,0 +1,391 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + shopifyApiRequest, +} from './GenericFunctions'; + +import { createHmac } from 'crypto'; + +export class ShopifyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Shopify Trigger', + name: 'shopify', + icon: 'file:shopify.png', + group: ['trigger'], + version: 1, + description: 'Handle Shopify events via webhooks', + defaults: { + name: 'Shopify Trigger', + color: '#559922', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'shopifyApi', + required: true, + } + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Topic', + name: 'topic', + type: 'options', + default: '', + options: + [ + { + name: 'app uninstalled', + value: 'app/uninstalled' + }, + { + name: 'carts create', + value: 'carts/create' + }, + { + name: 'carts update', + value: 'carts/update' + }, + { + name: 'checkouts create', + value: 'checkouts/create' + }, + { + name: 'checkouts delete', + value: 'checkouts/delete' + }, + { + name: 'checkouts update', + value: 'checkouts/update' + }, + { + name: 'collection_listings add', + value: 'collection_listings/add' + }, + { + name: 'collection_listings remove', + value: 'collection_listings/remove' + }, + { + name: 'collection_listings update', + value: 'collection_listings/update' + }, + { + name: 'collections create', + value: 'collections/create' + }, + { + name: 'collections delete', + value: 'collections/delete' + }, + { + name: 'collections update', + value: 'collections/update' + }, + { + name: 'customer_groups create', + value: 'customer_groups/create' + }, + { + name: 'customer_groups delete', + value: 'customer_groups/delete' + }, + { + name: 'customer_groups update', + value: 'customer_groups/update' + }, + { + name: 'customers create', + value: 'customers/create' + }, + { + name: 'customers delete', + value: 'customers/delete' + }, + { + name: 'customers disable', + value: 'customers/disable' + }, + { + name: 'customers enable', + value: 'customers/enable' + }, + { + name: 'customers update', + value: 'customers/update' + }, + { + name: 'draft_orders create', + value: 'draft_orders/create' + }, + { + name: 'draft_orders delete', + value: 'draft_orders/delete' + }, + { + name: 'draft_orders update', + value: 'draft_orders/update' + }, + { + name: 'fulfillment_events create', + value: 'fulfillment_events/create' + }, + { + name: 'fulfillment_events delete', + value: 'fulfillment_events/delete' + }, + { + name: 'fulfillments create', + value: 'fulfillments/create' + }, + { + name: 'fulfillments update', + value: 'fulfillments/update' + }, + { + name: 'inventory_items create', + value: 'inventory_items/create' + }, + { + name: 'inventory_items delete', + value: 'inventory_items/delete' + }, + { + name: 'inventory_items update', + value: 'inventory_items/update' + }, + { + name: 'inventory_levels connect', + value: 'inventory_levels/connect' + }, + { + name: 'inventory_levels disconnect', + value: 'inventory_levels/disconnect' + }, + { + name: 'inventory_levels update', + value: 'inventory_levels/update' + }, + { + name: 'locales create', + value: 'locales/create' + }, + { + name: 'locales update', + value: 'locales/update' + }, + { + name: 'locations create', + value: 'locations/create' + }, + { + name: 'locations delete', + value: 'locations/delete' + }, + { + name: 'locations update', + value: 'locations/update' + }, + { + name: 'order_transactions create', + value: 'order_transactions/create' + }, + { + name: 'orders cancelled', + value: 'orders/cancelled' + }, + { + name: 'orders create', + value: 'orders/create' + }, + { + name: 'orders delete', + value: 'orders/delete' + }, + { + name: 'orders fulfilled', + value: 'orders/fulfilled' + }, + { + name: 'orders paid', + value: 'orders/paid' + }, + { + name: 'orders partially_fulfilled', + value: 'orders/partially_fulfilled' + }, + { + name: 'orders updated', + value: 'orders/updated' + }, + { + name: 'product_listings add', + value: 'product_listings/add' + }, + { + name: 'product_listings remove', + value: 'product_listings/remove' + }, + { + name: 'product_listings update', + value: 'product_listings/update' + }, + { + name: 'products create', + value: 'products/create' + }, + { + name: 'products delete', + value: 'products/delete' + }, + { + name: 'products update', + value: 'products/update' + }, + { + name: 'refunds create', + value: 'refunds/create' + }, + { + name: 'shop update', + value: 'shop/update' + }, + { + name: 'tender_transactions create', + value: 'tender_transactions/create' + }, + { + name: 'themes create', + value: 'themes/create' + }, + { + name: 'themes delete', + value: 'themes/delete' + }, + { + name: 'themes publish', + value: 'themes/publish' + }, + { + name: 'themes update', + value: 'themes/update' + } + ], + description: 'Event that triggers the webhook', + }, + ], + + }; + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId === undefined) { + return false; + } + const endpoint = `/webhooks/${webhookData.webhookId}.json`; + try { + await shopifyApiRequest.call(this, 'GET', endpoint, {}); + } catch (e) { + if (e.statusCode === 404) { + delete webhookData.webhookId; + return false; + } + throw e; + } + return true; + }, + async create(this: IHookFunctions): Promise { + const credentials = this.getCredentials('shopifyApi'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const topic = this.getNodeParameter('topic') as string; + const endpoint = `/webhooks.json`; + const body = { + webhook: { + topic, + address: webhookUrl, + format: 'json', + } + }; + + let responseData; + try { + responseData = await shopifyApiRequest.call(this, 'POST', endpoint, body); + } catch(error) { + return false; + } + + if (responseData.webhook === undefined || responseData.webhook.id === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.webhook.id as string; + webhookData.sharedSecret = credentials!.sharedSecret as string; + webhookData.topic = topic as string; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = `/webhooks/${webhookData.webhookId}.json`; + try { + await shopifyApiRequest.call(this, 'DELETE', endpoint, {}); + } catch (e) { + return false; + } + delete webhookData.webhookId; + delete webhookData.sharedSecret; + delete webhookData.topic; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const headerData = this.getHeaderData() as IDataObject; + const req = this.getRequestObject(); + const webhookData = this.getWorkflowStaticData('node') as IDataObject; + if (headerData['x-shopify-topic'] !== undefined + && headerData['x-shopify-hmac-sha256'] !== undefined + && headerData['x-shopify-shop-domain'] !== undefined + && headerData['x-shopify-api-version'] !== undefined) { + // @ts-ignore + const computedSignature = createHmac('sha256', webhookData.sharedSecret as string).update(req.rawBody).digest('base64'); + if (headerData['x-shopify-hmac-sha256'] !== computedSignature) { + return {}; + } + if (webhookData.topic !== headerData['x-shopify-topic']) { + return {}; + } + } else { + return {}; + } + return { + workflowData: [ + this.helpers.returnJsonArray(req.body) + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Shopify/shopify.png b/packages/nodes-base/nodes/Shopify/shopify.png new file mode 100644 index 0000000000000000000000000000000000000000..ab5993cb65d6d3d1b25b9cef0320bd54736737f4 GIT binary patch literal 6495 zcmY*ecQhPM*Ip%hCpuX@(N-^O5xv*wB&@PgqOP(82^P_b8Z|o6OOOQ7ONib(t9Q}+ zE5G;s-tYV7oSA#)&NI(5cjk|ob0$n%Q;86d77qXb5UME4>pob>--?6v(1LHK-5v}Y zTvtgJP(DDv_0Yj}Q8tDH0Qls8D;gj*o%#V_Y^P`BZls|OwsLmlg<3mX+VCPAT^`U6 zObGCU>uBQ+1tJ{ZIKjaPN#=hL;0OP27{m+JR##4j!`4&oC42?+2!Ab8;3PVP_y zj}x5b-z5LTBX0w@ay`_m{mSs>c z-L!#@ZGt(@Z3}T|Dh!+~GU$>Oba|hda?)ZS5t+p^^W`Z0aUP!)^eo_s`OMtPdi{WL zx~c1~#Zh;`o7+0CzG#&B?X+-NClKM|X}JGA&Z?P{D!033=+A`sK4H63U)twe9;bn6 zbhyD?!`7+OjQEn|_-JioWog1ypncBZ>TRU(uJ^ATn2Nj_`+)~{Z-v;I!O`PxTqzCM zwE0u)wA9z%CmE#m2yTxKo14ca4n*0nLe&RyXr-s>VS~s1;#c!^;xzdT$7{|%$51{` zXy_{}&q|+$)TZ;de~Gb8m=UYA)GzP#U)pR96o~U#ikKv1g#N7G`(?^qwl5r~LwY4z zI(=HdTc2=RQbjIh4$6LHaes$8YVXVtT!)p53s^4pqaEaD7I++9AcSj~&ob;JSV9zj z6vM-6Cr9=LHX-}-l{?}j>?XrqSN)~s>5`rT7sMy`9;$p-F-dqU$!-%<%DbNk?y%bT*bB3;M^Q=uL(thkU{J?pd!pT+E+LvG5O87>z<^U%uVdhW6O*q~Y?z zP!sx-sN1BtT$|Jp#Q;B;4PBP=9*6-s(cSX0Ahs`k@ux@UZ=8-STK{@_w4Eq8<%V=& z)OT3#41V}lBS$&)$-VXU7f5Z!@tNoAj+1Qf5P`PFWX~&Dn>pzu{+`CTG z8k8_ok=JMHt<9uw5yxl4K`7~&O4vCN_m9)OaQ{Z&@tm3%1DOMV+$~1muv$Z5gcVcq zlb~;+7|X4Gw`mGR;EJDBt~>gR(=MmcRN_CspT*rYNa*9QUqsa0-&+ zbm;R1vB^Vfo}7fpAm7{jW!z2;CfnsY2NpR21PuX7g5n{SNl5>^@LUqxsa->|Fly^2y|2C;ndHhr;vKwlFK0|QM z2ey-ZqreMvR*h;It+!k?p40(BFY?)EFDoK8=zZtu8n(HR0b0v92$+D~A5^U{B%tSp zedSAdqh8)PC!TlUNco~RaTBcbiM2xf0j2ND?D?4lAE0s(M3q8aL$<9Q>WqMa- z)45x1BbD`gswZ8x&$UeHv&t~=oaF-WXpdzV6X`G9#-Z&hnVwcnf<-c6>r3QHo*K>Tayk|jbdn~ z-PWzhZQg6eY1jR(t1P6UBCs2ma_I_RL~Ff_}of0TOFGrB|EJ!3KmkVt!I}Yz(+(Osd?j$Z%e{a zZmMr&*T@q1NO?P-CofsdV5~3vM7}yA@>Tz(dqJ9Lg+mWjHyx3CT{Mp1yI>)rs+u51 zst>)JW9npdn~xqJ9!zxQbbe`ExmC zsTL0kR5f}cw$p2}JnkN0akN&@xGK!nOfD}v=kl{|2U6If@cj& z+@>_7*3FG|Tu`{8bAW6SZG3oGI}YjiVHM%eWIIc&?!znQ$k9G$G-SHLt;Z<3_o$g-zhkpu&4wR|wLZW%n~0v2_4aUbcblEj7U!kJ`rs zdx}4rk7K3!?L*l-mp1}l$gz2fz9FP>0<8w1m4IPVtn*#p$x|{^?F05~lT8?;X>OuW zSmqjoZCl}5sUBxxtr(PA#2iH25hA%cTV;Mnhg|`Q;%+^gnRxMYs(~_0CGzb__H76` zc7Ssnbga8t#v%zYiZ2K*I2$9Rk&^P!rjACRUlUR3>KNa)dX^r&W<=CZF}Hef@^B5j zwf=Z{;;xrb zV`L7d#MPb@di>F`ij+5(jXsz-JgSkXHefWcLnw^GNX>&OXoV}PK(W0VqAQfmkFABK9ZA1_B z-zYvsjGm!@OUpWI|O(w zP+7}Ez|tkmgpbZprBsshL@hwTMU^JtSV5qRajbh(sDZXQdfADUUP#=|1VmEY);3bU ziknrux1x-g+Z+kn-7!lNJb%Scg2=3faBn{2lN#n@kBs+Tb2RpThOSFIqI*}Gj|OKe z=iF;Acdf%@8FPsutn|u3%Vfg*?S6!AF&g^LQ}#DQD!1|{?H|-XLM&UP6;yFCkFDg< zO>a5+m-tM?9^b`JUN3T32{JwnSK*kba-IF96&Uh^FKpwLne3*_ zjPU^zTA&FuIxH;TDkM%*tDCQ3d_}1?GtGgBCRMY@i!QWarW5dNm^3D-$0;TgyDOsCGA#+Zq%jg8n2KanOCD}9L^y_;> z+GD&QYrd@FAJ;iLE(70jlV4SkO_)lONbyghV%eQHeu-$`*qxzTq`EMqqhm-H2qgvZ z&>ZO)hd8pAs%f#CjDQb#qsso?vwm;#yw&5I^iiX z7pYVsC6bId3exOpEanY%hY1#Fxw_fyF?BsFQas8d%AabW{{0x3VPgxQ@d6jXut$`f znjNOPp8b4)PbQj^2w`$C4doeXs6#nOe|w_Hh8j>4OHBf&DB@+@l%ek{Aa`-+)7!P#sKHUC6K_ZhJH4A39H=!*d>UINM9_Bw z^k|!qs7Rqmv3RHhxY0kKYm&}jj4icR4-lqBf`f&sC7vdx!;EF#3$lwqZO`8djkKn@ zwtNu&_chDoa@6*I@BQxdDO(roQ37zFSU7a(aIt|&f*C>Ob5{3i4_)D zUHgxk`3+DYb|11IMd&g|CmZdDVR3t7;VJ5v4>Igqg0HH;1G%A?{fVX zw31#tgZbin0{$vOsqRVVpMeD%r;{Q!)tP9Y1o$9 zrIo0HAYYV3Sk)-)5R(cM7?N1)OJIs5*JFJyi-wXhP~`GWzIjKI{K_ra{l084DdoM3 zY@pH$l6M#V-o_^C<_As{5XKwHb&^2k7Y)CveO0J}FgUAyn1YH!_(Ahh!ttx2%f-Dd zI&B~OnYXq#ff&zJx4u9Td|^CEL=w+6CCIT;R0$*d!|#n;RIw5xuU89*{rYps3vgvZ zPOR$pK~3<>B33b_i0%Wf=n&@|TFU@b2tn=(Gge`BR#!n|*S8lkYlThYT1JmHF1as) zw)5$8TOiJ~AgI>rRWjl9v(!Fz#ZF6o~4NMbG&(j2DQM^BAF9gw6nMlT`Fh)$*2h#oWJgC?1q|< zkDm8(jH=vBsHOqqG(x&WQR`xDLj03{c7Aij zQX_cWkXriXgL+`aWmu#(U>U-en=)lRHAnKMx?+#|ajT!g)y>!Pu6) zlsxpbNx-3pyNw2+@*OIQ1gZG$E_ z(JqlH?cAh?#5B@|$KoI^ZX(t0RLyhsLPfTa9>1iYCLLRSK%Gpqrr6!mlMR`bm&{18(JAd<4Vbar=*keDA$94hAHIEpbQ%{qe(rH>P;9(1xTv z&(;dd*NbHn+hL3qUdK9`#2L1v={CrDrwZMnA=>lYP55R>t4B%KA|uyAjR*IQei<`& zbIkAjyZo(dng^5mrP`Wot;dD!mN&q6cd%l%%G7Op90PY)4Cd9{c@$X&lFcYBgX&_; zTNaBM*6V_zAhaCPcmZw~!cmb`;i|^Gof4ql+&j)r{j;fsyoQ1|V>vvMK?MQE{Ov}# zNwDGmqnkLOY8T@O=obB_Xl2EhE!CIYTM&;gQH%^9a$>}2sLnf?IC0`FA(Grz$ZtJl zQ0R;GC`ZFh^&yR0%s)T%OJ=4Z-(>dpR&x}GQjmIIyZ zZxL^=wb#~6{lwft4kq0h7Mtt%l%;x0o-OYtpP%jH33Tx~%GEN$;%#L|Z;o6RkRB03 zF@uu1u_CK?zc(9xNq>$aEgfm_GQt(F_SM#vmvj^t^hvBP%pcH~MudqdfRhAt@pjcl=-; z*Tp!79hWYt5*g&4xqkSHJJl9p-zMybtni$#d~kidvsCN2{Bhbbtx>OD$XTQ)N$;A6 zq`NbTp|Sfp*>OK@-K{Cx+sDZ5zIl69UxvWrCIDOs&m!frJykg)1)eT>wQ*O_8m~w#p9{oBp=jE7q%;{grMq8l5aav_RWVay0AZx@WYpnrS!w~mMM5OU9+RUI!v(ej}vYNb8;ie94XS=k&=7evTeKyzK0<=oy!~?ISn=sctYPr7wHQuazblv5DU( zIm&mt4d#Fvrf8d__!=0hp#hLZG63a`p6wu`*CTDSVSWL9*Qh%3c8g_x_58`2KxWDygPP*#AFc0aV_FEbr9km2oGe?h zCJ!KXPF`^#9C;&a{n660t4&BqMfC1N7#sQ+@ynmrgeQJCAoB*#2?W0xPM;BFLVxh) z4;s9yhhoG z?5jF|VuMxVO&_rd36^^?gmGC7)1%x~(P$BzGElzo;;|d!4WXIhk-uWG;o-p|ZvWL- z%9DXmY%{Ruub%*jN4vxlxtxcTk=Y04_xR8TNX!-6T@VQ~J^OP^+#EJQv;@9rJ|Yd6 zu$ix)VCz2JWkV!EO0P4>%y(yL5UiZ;JKR3V-O;(SB};hW+0nKh-uGSEd$zRtx7AAP zzDF7%N?W)+keuD3u(Q!vvM=YfH~CAc7TQwO>Y@mG(DV8Gxp4*@Km;Dk-DLygPwM4s zG7>jZ9HYRw*){%;c}K@`cmV~`VPoj9cCx29PCTS&jBTc?5%n9iIBFwGJK5{gtbv;Z zoY^$5C|%Nz7_z{O_`jA*K0A=qHwJAkNa#WCXbTZjp1zu}sk~lHY*JIUVvA3#8iOhD zLT>>|bi&!iN5K31dY*=P^Lfip!{sZ=?#;CMBxCu2U96LD4IXa-L9z1ay^(Kl^`5iv z4M@f`Ra71v$)wUPf&mp2)SpG^`llN|zlL;+CNcd9oK!=#_PN;q(4c4{sA}3j-S@_Z zvx|n4vT7R?4$If1r1?mtMkpai#a