diff --git a/index.js b/index.js index 14616cf..0e42524 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,8 @@ const stopcock = require('stopcock'); const got = require('got'); const url = require('url'); +const isPlainObject = require('lodash/isPlainObject'); + const pkg = require('./package'); const resources = require('./resources'); @@ -292,9 +294,27 @@ Shopify.prototype.graphql = function graphql(data, variables) { this.updateGraphqlLimits(res.body.extensions.cost); } + // see https://shopify.dev/docs/apps/launch/protected-customer-data#graphql-admin-api-request-with-unapproved-fields if (Array.isArray(res.body.errors)) { - // Make Got consider this response errored and retry if needed. - throw new Error(res.body.errors[0].message); + const isProtectedCustomerDataError = res.body.errors.every((error) => { + return ( + error && + isPlainObject(error.extensions) && + error.extensions.code === 'ACCESS_DENIED' && + error.extensions.documentation && + error.extensions.requiredAccess + ); + }); + + if ( + isProtectedCustomerDataError && + this.options.onProtectedCustomerDataError + ) { + this.options.onProtectedCustomerDataError(res.body.errors); + } else { + // Make Got consider this response errored and retry if needed. + throw new Error(res.body.errors[0].message); + } } } diff --git a/test/shopify.test.js b/test/shopify.test.js index f512584..654589e 100644 --- a/test/shopify.test.js +++ b/test/shopify.test.js @@ -917,6 +917,134 @@ describe('Shopify', () => { ); }); + it('throws an error on protected customer data errors by default', () => { + const shopify = new Shopify({ shopName, accessToken }); + + scope.post('/admin/api/graphql.json').reply(200, { + errors: [ + { + message: + 'This app is not approved to use the email field. See https://partners.shopify.com/1/apps/1/customer_data for more details.', + path: ['customers', 'edges', '0', 'node', 'email'], + extensions: { + code: 'ACCESS_DENIED', + documentation: + 'https://partners.shopify.com/1/apps/1/customer_data', + requiredAccess: + 'Shopify approval is required before using the email field.' + } + } + ] + }); + + return shopify.graphql('query').then( + () => { + throw new Error('Test invalidation'); + }, + (err) => { + expect(err).to.be.an.instanceof(Error); + expect(err.message).to.equal( + 'This app is not approved to use the email field. See https://partners.shopify.com/1/apps/1/customer_data for more details.' + ); + } + ); + }); + + it('calls a provided onProtectedCustomerDataError hook when a protected customer data error occurs', () => { + const customerDataErrors = [ + { + message: + 'This app is not approved to use the email field. See https://partners.shopify.com/1/apps/1/customer_data for more details.', + path: ['customers', 'edges', '0', 'node', 'email'], + extensions: { + code: 'ACCESS_DENIED', + documentation: + 'https://partners.shopify.com/1/apps/1/customer_data', + requiredAccess: + 'Shopify approval is required before using the email field.' + } + }, + { + message: + 'This app is not approved to use the firstName field. See https://partners.shopify.com/1/apps/1/customer_data for more details.', + path: ['customers', 'edges', '0', 'node', 'firstName'], + extensions: { + code: 'ACCESS_DENIED', + documentation: + 'https://partners.shopify.com/1/apps/1/customer_data', + requiredAccess: + 'Shopify approval is required before using the firstName field.' + } + } + ]; + + let calledWithErrors = undefined; + + const shopify = new Shopify({ + shopName, + accessToken, + onProtectedCustomerDataError: (errors) => { + calledWithErrors = errors; + } + }); + + scope.post('/admin/api/graphql.json').reply(200, { + errors: customerDataErrors + }); + + return shopify.graphql('query').then(() => { + expect(calledWithErrors).to.deep.equal(customerDataErrors); + }); + }); + + it('throws an error if the onProtectedCustomerDataError hook is provided but not all errors are customer data errors', () => { + const shopify = new Shopify({ shopName, accessToken }); + + scope.post('/admin/api/graphql.json').reply(200, { + errors: [ + { + message: "Field 'foo' doesn't exist on type 'QueryRoot'", + locations: [ + { + line: 1, + column: 3 + } + ], + path: ['query', 'foo'], + extensions: { + code: 'undefinedField', + typeName: 'QueryRoot', + fieldName: 'foo' + } + }, + { + message: + 'This app is not approved to use the email field. See https://partners.shopify.com/1/apps/1/customer_data for more details.', + path: ['customers', 'edges', '0', 'node', 'email'], + extensions: { + code: 'ACCESS_DENIED', + documentation: + 'https://partners.shopify.com/1/apps/1/customer_data', + requiredAccess: + 'Shopify approval is required before using the email field.' + } + } + ] + }); + + return shopify.graphql('query').then( + () => { + throw new Error('Test invalidation'); + }, + (err) => { + expect(err).to.be.an.instanceof(Error); + expect(err.message).to.equal( + "Field 'foo' doesn't exist on type 'QueryRoot'" + ); + } + ); + }); + it('uses basic auth as intended', () => { const shopify = new Shopify({ shopName, apiKey, password }); diff --git a/types/index.d.ts b/types/index.d.ts index 24976d2..20846ba 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -786,6 +786,16 @@ declare namespace Shopify { interval: number; } + export interface IProtectedCustomerDataError { + message: string; + path: string[]; + extension: { + code: 'ACCESS_DENIED'; + documentation: string; + requiredAccess: string; + }; + } + export interface IPublicShopifyConfig { accessToken: string; apiVersion?: string; @@ -796,6 +806,9 @@ declare namespace Shopify { timeout?: number; hooks?: Hooks; agent?: Agents; + onProtectedCustomerDataError?: ( + errors: IProtectedCustomerDataError[] + ) => void; } export interface IPrivateShopifyConfig {