Skip to content

Commit

Permalink
feat: Multi-client support (#398)
Browse files Browse the repository at this point in the history
* update jest

To include fix for:
jestjs/jest#6730

* Remove noEmitOnError from tsconfig

* Remove unneeded mock

* Fix test

Not sure why, but jest seems to have trouble loading some modules, the filter(Boolean) makes the test pass

* Dedupe packages

* Accept keyPrefix in config to allow per client storage in multi-client

* Pass keyPrefix when creating store

* Typescript fixes

* Add colon to keyPrefix in store

* Remove unused import

* Return false instead of undefined when response is not optimistic

* Enforce unique keyPrefixes among clients

Unit tests refactoring to account for module state (keyPrefixesInUse)

* Improve typings for auth options

* Make invalid auth type error non retryable

* Add test coverage for AWS_IAM auth mode

* Kepp client instances around to prevent garbage collection

* Use default prefixKey from redux-persist
  • Loading branch information
manueliglesias authored May 14, 2019
1 parent 5af8c08 commit 1885a8a
Show file tree
Hide file tree
Showing 11 changed files with 1,567 additions and 518 deletions.
289 changes: 251 additions & 38 deletions packages/aws-appsync/__tests__/client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import gql from "graphql-tag";
import { v4 as uuid } from "uuid";
import { Observable } from "apollo-link";
import { createHttpLink } from "apollo-link-http";
import { AWSAppSyncClientOptions, AWSAppSyncClient, AUTH_TYPE, ConflictResolutionInfo, ConflictResolver } from "../src/client";
import { Store } from "redux";
import { OfflineCache } from "../src/cache/offline-cache";
import { NormalizedCacheObject } from "apollo-cache-inmemory";
import { isOptimistic } from "../src/link/offline-link";
import { GraphQLError } from "graphql";
import { ApolloError } from "apollo-client";
import { AWSAppsyncGraphQLError } from "../src/types";

jest.mock('apollo-link-http-common', () => ({
checkFetcher: () => { },
}));

jest.mock('apollo-link-http', () => ({
createHttpLink: jest.fn(),
}));
import { DEFAULT_KEY_PREFIX } from "../src/store";

let setNetworkOnlineStatus: (online: boolean) => void;
jest.mock("@redux-offline/redux-offline/lib/defaults/detectNetwork", () => (callback) => {
Expand All @@ -28,35 +19,55 @@ jest.mock("@redux-offline/redux-offline/lib/defaults/detectNetwork", () => (call
// Setting initial network online status
callback({ online: true });
});
jest.mock('apollo-link-http', () => ({
createHttpLink: jest.fn(),
}));
let mockHttpResponse: (responses: any[] | any, delay?: number) => void;
let factory: (opts: AWSAppSyncClientOptions) => AWSAppSyncClient<any>;
let isOptimistic;
let Signer;
beforeEach(() => {
let createHttpLink;
jest.resetModules();
jest.isolateModules(() => {
const { AWSAppSyncClient } = require('../src/client');
({ isOptimistic } = require("../src/link/offline-link"));
({ createHttpLink } = require("apollo-link-http"));
({ Signer } = require("../src/link/signer"));

factory = (opts) => {
return new AWSAppSyncClient(opts);
};
});

const getStoreState = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => ((client as any)._store as Store<OfflineCache>).getState();

const isNetworkOnline = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => getStoreState(client).offline.online;
mockHttpResponse = (responses: any[] | any, delay:number = 0) => {
const mock = (createHttpLink as jest.Mock);

const getOutbox = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => getStoreState(client).offline.outbox;
const requestMock = jest.fn();

const mockHttpResponse = (responses: any[] | any, delay = 0) => {
[].concat(responses).forEach((resp) => {
requestMock.mockImplementationOnce(() => new Observable(observer => {
const timer = setTimeout(() => {
observer.next({ ...resp });
observer.complete();
}, delay);

const mock = (createHttpLink as jest.Mock);
// On unsubscription, cancel the timer
return () => clearTimeout(timer);
}));
});

const requestMock = jest.fn();
mock.mockImplementation(() => ({
request: requestMock
}));
};
});

[].concat(responses).forEach((resp) => {
requestMock.mockImplementationOnce(() => new Observable(observer => {
const timer = setTimeout(() => {
observer.next({ ...resp });
observer.complete();
}, delay);
const getStoreState = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => ((client as any)._store as Store<OfflineCache>).getState();

// On unsubscription, cancel the timer
return () => clearTimeout(timer);
}));
});
const isNetworkOnline = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => getStoreState(client).offline.online;

mock.mockImplementation(() => ({
request: requestMock
}));
};
const getOutbox = <T extends NormalizedCacheObject>(client: AWSAppSyncClient<T>) => getStoreState(client).offline.outbox;

class MemoryStorage {
private storage;
Expand All @@ -71,7 +82,7 @@ class MemoryStorage {
this.logger(...args)
}
}
setItem(key, value, callback) {
setItem(key, value, callback?) {
return new Promise((resolve, reject) => {
this.storage[key] = value
this.log('setItem called with', key, value)
Expand All @@ -80,7 +91,7 @@ class MemoryStorage {
})
}

getItem(key, callback) {
getItem(key, callback?) {
return new Promise((resolve, reject) => {
this.log('getItem called with', key)
const value = this.storage[key]
Expand All @@ -89,7 +100,7 @@ class MemoryStorage {
})
}

removeItem(key, callback) {
removeItem(key, callback?) {
return new Promise((resolve, reject) => {
this.log('removeItem called with', key)
const value = this.storage[key]
Expand All @@ -99,7 +110,7 @@ class MemoryStorage {
})
}

getAllKeys(callback) {
getAllKeys(callback?) {
return new Promise((resolve, reject) => {
this.log('getAllKeys called')
const keys = Object.keys(this.storage)
Expand All @@ -110,7 +121,7 @@ class MemoryStorage {
}

const getClient = (options?: Partial<AWSAppSyncClientOptions>) => {
const defaultOptions = {
const defaultOptions: AWSAppSyncClientOptions = {
url: 'some url',
region: 'some region',
auth: {
Expand All @@ -120,11 +131,11 @@ const getClient = (options?: Partial<AWSAppSyncClientOptions>) => {
disableOffline: false,
offlineConfig: {
storage: new MemoryStorage(),
callback: null, // console.warn,
callback: null,
},
};

const client = new AWSAppSyncClient({
const client: AWSAppSyncClient<any> = factory({
...defaultOptions,
...options,
offlineConfig: {
Expand Down Expand Up @@ -903,3 +914,205 @@ describe("Offline enabled", () => {

// missing update function
});

describe("Multi client", () => {
test("Can pass a prefix and it is used", async () => {
const storage = new MemoryStorage();

mockHttpResponse({
data: {
someQuery: {
__typename: 'someType',
someField: 'someValue'
}
}
});

const client = getClient({
disableOffline: false,
offlineConfig: {
keyPrefix: 'myPrefix',
storage,
}
});

await client.hydrated();

await client.query({
query: gql`query {
someQuery {
someField
}
}`
});

// Give it some time
await new Promise(r => setTimeout(r, WAIT));

const allKeys = await storage.getAllKeys() as string[];

expect(allKeys.length).toBeGreaterThan(0);
allKeys.forEach(key => expect(key).toMatch(/^myPrefix:.+/));
});

test.each([false, null, ''])("Uses default prefix for falsey (%o) keyPrefix", async (keyPrefix: any) => {
const storage = new MemoryStorage();
mockHttpResponse({
data: {
someQuery: {
__typename: 'someType',
someField: 'someValue'
}
}
});

const client = getClient({
disableOffline: false,
offlineConfig: {
keyPrefix,
storage,
}
});

await client.hydrated();

await client.query({
query: gql`query {
someQuery {
someField
}
}`
});

// Give it some time
await new Promise(r => setTimeout(r, WAIT));

const allKeys = await storage.getAllKeys() as string[];

expect(allKeys.length).toBeGreaterThan(0);
allKeys.forEach(key => expect(key).toMatch(new RegExp(`^${DEFAULT_KEY_PREFIX}:.+`)));
});

test("Can use different prefixes", async () => {
const prefixes = ['myPrefix1', 'myPrefix2', 'myPrefix3'];

const instances = [];

for (let keyPrefix of prefixes) {
const storage = new MemoryStorage();
mockHttpResponse({
data: {
someQuery: {
__typename: 'someType',
someField: 'someValue'
}
}
});

const client = getClient({
disableOffline: false,
offlineConfig: {
keyPrefix,
storage,
}
});

instances.push(client);

await client.hydrated();

await client.query({
query: gql`query {
someQuery {
someField
}
}`
});

// Give it some time
await new Promise(r => setTimeout(r, WAIT));

const allKeys = await storage.getAllKeys() as string[];

expect(allKeys.length).toBeGreaterThan(0);
allKeys.forEach(key => expect(key).toMatch(new RegExp(`^${keyPrefix}:.+`)));
};

expect(instances.length).toEqual(prefixes.length);
});

test('Cannot use same keyPrefix more than once', () => {
getClient({
disableOffline: false,
offlineConfig: {
keyPrefix: 'myPrefix',
}
});

expect(() => {
getClient({
disableOffline: false,
offlineConfig: {
keyPrefix: 'myPrefix',
}
});
}).toThrowError('The keyPrefix myPrefix is already in use. Multiple clients cannot share the same keyPrefix.');
});
});

describe('Auth modes', () => {
test('AWS_IAM calls signer', async () => {
const signerSpy = jest.spyOn(Signer, 'sign');

mockHttpResponse({
data: {
someQuery: {
__typename: 'someType',
someField: 'someValue'
}
}
});

const credentials = {
accessKeyId: 'access',
secretAccessKey: 'secret',
sessionToken: 'session',
};

const client = getClient({
disableOffline: false,
url: 'https://somehost/graphql',
auth: {
type: AUTH_TYPE.AWS_IAM,
credentials: () => credentials
}
});

await client.hydrated();

await client.query({
query: gql`query {
someQuery {
someField
}
}`,
fetchPolicy: "network-only"
});

// Give it some time
await new Promise(r => setTimeout(r, WAIT));

expect(signerSpy).toHaveBeenCalledWith(expect.anything(), {
access_key: credentials.accessKeyId,
secret_key: credentials.secretAccessKey,
session_token: credentials.sessionToken,
});
expect(signerSpy).toReturnWith(expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^AWS4\-HMAC\-SHA256 Credential=/),
'X-Amz-Security-Token': 'session',
'x-amz-date': expect.stringMatching(/^\d{8}T\d{6}Z$/),
})
}));
});
});
8 changes: 4 additions & 4 deletions packages/aws-appsync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@redux-offline/redux-offline": "2.2.1",
"apollo-cache-inmemory": "1.3.10",
"apollo-client": "2.4.6",
"apollo-link": "1.2.2",
"apollo-link": "1.2.3",
"apollo-link-context": "1.0.9",
"apollo-link-http": "1.3.1",
"apollo-link-retry": "2.2.5",
Expand All @@ -37,13 +37,13 @@
},
"devDependencies": {
"@types/graphql": "0.12.4",
"@types/jest": "^23.3.1",
"@types/jest": "24",
"@types/node": "^8.0.46",
"@types/zen-observable": "^0.5.3",
"graphql-tag": "^2.9.2",
"jest": "^23.4.2",
"jest": "24",
"node-fetch": "^2.2.0",
"ts-jest": "^23.0.1",
"ts-jest": "24",
"typescript": "^3.0.1"
}
}
Loading

0 comments on commit 1885a8a

Please sign in to comment.