Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #84 from Shopify/add-graphql-proxy
Browse files Browse the repository at this point in the history
Add GraphQL proxy functionality
  • Loading branch information
carmelal authored Feb 9, 2021
2 parents 839b8cd + 06537c8 commit ce3671a
Show file tree
Hide file tree
Showing 6 changed files with 1,419 additions and 826 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@shopify/network": "^1.5.1",
"@types/jsonwebtoken": "^8.5.0",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.10",
"cookies": "^0.8.0",
"jsonwebtoken": "^8.5.1",
"node-fetch": "^2.6.1",
Expand All @@ -44,9 +45,11 @@
"@types/uuid": "^8.3.0",
"eslint": "^7.9.0",
"eslint-plugin-tsdoc": "^0.2.7",
"express": "^4.17.1",
"jest": "^26.4.2",
"jest-fetch-mock": "^3.0.3",
"rimraf": "^3.0.2",
"supertest": "^6.1.3",
"ts-jest": "^26.4.1",
"typedoc": "^0.19.2",
"typescript": "^3.8.0"
Expand Down
5 changes: 5 additions & 0 deletions src/clients/graphql/graphql_client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {MissingRequiredArgument} from '../../error';
import {Context} from '../../context';
import {ShopifyHeader} from '../../base_types';
import {HttpClient} from '../http_client/http_client';
Expand All @@ -13,6 +14,10 @@ export class GraphqlClient {
}

async query(params: GraphqlParams): Promise<RequestReturn> {
if (params.data.length === 0) {
throw new MissingRequiredArgument('Query missing.');
}

params.extraHeaders = {[ShopifyHeader.AccessToken]: this.token, ...params.extraHeaders};
const path = `/admin/api/${Context.API_VERSION}/graphql.json`;

Expand Down
62 changes: 62 additions & 0 deletions src/utils/graphql_proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import http from 'http';

import {GraphqlClient} from '../clients/graphql';
import * as ShopifyErrors from '../error';

import loadCurrentSession from './load-current-session';

export default async function graphqlProxy(userReq: http.IncomingMessage, userRes: http.ServerResponse): Promise<void> {
const session = await loadCurrentSession(userReq, userRes);
if (!session) {
throw new ShopifyErrors.SessionNotFound(
'Cannot proxy query. No session found.',
);
} else if (!session.accessToken) {
throw new ShopifyErrors.InvalidSession(
'Cannot proxy query. Session not authenticated.',
);
}

const shopName: string = session.shop;
const token: string = session.accessToken;
let query = '';

const promise: Promise<void> = new Promise((resolve, _reject) => { // eslint-disable-line promise/param-names
userReq.on('data', (chunk) => {
query += chunk;
});

userReq.on('end', async () => {
let body: unknown = '';
try {
const options = {
data: query,
};
const client = new GraphqlClient(shopName, token);
const response = await client.query(options);
body = response.body;
} catch (err) {
let status;
switch (err.constructor.name) {
case 'MissingRequiredArgument':
status = 400;
break;
case 'HttpResponseError':
status = err.code;
break;
case 'HttpThrottlingError':
status = 429;
break;
default:
status = 500;
}
userRes.statusCode = status;
body = err.message;
} finally {
userRes.end(JSON.stringify(body));
}
return resolve();
});
});
return promise;
}
30 changes: 16 additions & 14 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import validateHmac from './hmac-validator';
import validateShop from './shop-validator';
import safeCompare from './safe-compare';
import loadCurrentSession from './load-current-session';
import loadOfflineSession from './load-offline-session';
import decodeSessionToken from './decode-session-token';
import deleteCurrentSession from './delete-current-session';
import deleteOfflineSession from './delete-offline-session';
import storeSession from './store-session';
import decodeSessionToken from './decode-session-token';
import loadCurrentSession from './load-current-session';
import loadOfflineSession from './load-offline-session';
import nonce from './nonce';
import graphqlProxy from './graphql_proxy';
import safeCompare from './safe-compare';
import storeSession from './store-session';
import validateHmac from './hmac-validator';
import validateShop from './shop-validator';
import withSession from './with-session';

const ShopifyUtils = {
validateHmac,
validateShop,
safeCompare,
loadCurrentSession,
loadOfflineSession,
decodeSessionToken,
deleteCurrentSession,
deleteOfflineSession,
storeSession,
decodeSessionToken,
loadCurrentSession,
loadOfflineSession,
nonce,
graphqlProxy,
safeCompare,
storeSession,
validateHmac,
validateShop,
withSession,
};

Expand Down
114 changes: 114 additions & 0 deletions src/utils/test/graphql_proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import http from 'http';

import jwt from 'jsonwebtoken';
import express from 'express';
import request from 'supertest';

import '../../test/test_helper';
import {Session} from '../../auth/session';
import {InvalidSession, SessionNotFound} from '../../error';
import graphqlProxy from '../graphql_proxy';
import {Context} from '../../context';
import {JwtPayload} from '../decode-session-token';

const successResponse = {
data: {
shop: {
name: 'Shop',
},
},
};
const shopQuery = `{
shop {
name
}
}`;
const shop = 'shop.myshopify.com';
const accessToken = 'dangit';
let token = '';

describe('GraphQL proxy with session', () => {
beforeEach(async () => {
Context.IS_EMBEDDED_APP = true;
Context.initialize(Context);
const jwtPayload: JwtPayload = {
iss: 'https://shop.myshopify.com/admin',
dest: 'https://shop.myshopify.com',
aud: Context.API_KEY,
sub: '1',
exp: Date.now() / 1000 + 3600,
nbf: 1234,
iat: 1234,
jti: '4321',
sid: 'abc123',
};

const session = new Session(`shop.myshopify.com_${jwtPayload.sub}`);
session.shop = shop;
session.accessToken = accessToken;
await Context.SESSION_STORAGE.storeSession(session);
token = jwt.sign(jwtPayload, Context.API_SECRET_KEY, {algorithm: 'HS256'});
});

it('can forward query and return response', async () => {
const app = express();
app.post('/proxy', graphqlProxy);

fetchMock.mockResponseOnce(JSON.stringify(successResponse));
const response = await request(app)
.post('/proxy')
.set('authorization', `Bearer ${token}`)
.send(shopQuery)
.expect(200);

expect(JSON.parse(response.text)).toEqual(successResponse);
});

it('rejects if no query', async () => {
const app = express();
app.post('/proxy', graphqlProxy);

const response = await request(app)
.post('/proxy')
.set('authorization', `Bearer ${token}`)
.expect(400);

expect(JSON.parse(response.text)).toEqual('Query missing.');
});
});

describe('GraphQL proxy', () => {
it('throws an error if no token', async () => {
Context.IS_EMBEDDED_APP = true;
Context.initialize(Context);
const jwtPayload: JwtPayload = {
iss: 'https://test-shop.myshopify.io/admin',
dest: 'https://test-shop.myshopify.io',
aud: Context.API_KEY,
sub: '1',
exp: Date.now() / 1000 + 3600,
nbf: 1234,
iat: 1234,
jti: '4321',
sid: 'abc123',
};

const token = jwt.sign(jwtPayload, Context.API_SECRET_KEY, {algorithm: 'HS256'});
const req = {
headers: {
authorization: `Bearer ${token}`,
},
} as http.IncomingMessage;
const res = {} as http.ServerResponse;
const session = new Session(`test-shop.myshopify.io_${jwtPayload.sub}`);
Context.SESSION_STORAGE.storeSession(session);

await expect(graphqlProxy(req, res)).rejects.toThrow(InvalidSession);
});

it('throws an error if no session', async () => {
const req = {headers: {}} as http.IncomingMessage;
const res = {} as http.ServerResponse;
await expect(graphqlProxy(req, res)).rejects.toThrow(SessionNotFound);
});
});
Loading

0 comments on commit ce3671a

Please sign in to comment.