Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,19 @@ const SERVER_URL = `ws://localhost:${PORT}`;
export class GraphQLSubscriptionsFixture {
graphqlService = new GraphqlService();
server: WS;
#apiKeyMock: jest.SpyInstance | undefined;

constructor() {
this.graphqlService.setKey('permanentkey');
this.openServer();
jest
.spyOn(this.graphqlService as any, 'getServerUrl')
.mockReturnValue(SERVER_URL);
}

mockApiKeyFetching() {
this.#apiKeyMock = jest
jest
.spyOn(this.graphqlService as any, 'fetchTemporaryApiKey')
.mockResolvedValue(DUMMY_API_KEY);
}

unmockApiKeyFetching() {
this.#apiKeyMock?.mockRestore();
}

triggerSubscription(
query: DocumentNode | string = 'subscription { baba }',
subscriber:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ describe('GraphQL subscriptions', () => {

beforeEach(async () => {
fixture = new GraphQLSubscriptionsFixture();
fixture.mockApiKeyFetching();
});

afterEach(async () => {
Expand Down
22 changes: 1 addition & 21 deletions src/lib/services/graphql/__tests__/graphql-subscriptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ describe('GraphQL subscriptions', () => {
beforeEach(async () => {
fetchMock.enableMocks();
fixture = new GraphQLSubscriptionsFixture();
fixture.mockApiKeyFetching();
});

afterEach(async () => {
await fixture.cleanup();
fetchMock.mockClear();
});

it('sends connection init to start', async () => {
Expand Down Expand Up @@ -376,26 +376,6 @@ describe('GraphQL subscriptions', () => {
subscription.unsubscribe();
});

describe('API Key', () => {
it('fetches temporary API key', () => {
fixture.graphqlService.setKey('initialkey');
fixture.unmockApiKeyFetching();

fetchMock.mockResponseOnce(JSON.stringify({ key: '12345' }));
fixture.triggerSubscription();

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://api.qminder.com/graphql/connection-key',
{
headers: { 'X-Qminder-REST-API-Key': 'initialkey' },
method: 'POST',
mode: 'cors',
},
);
});
});

function useFakeSetInterval() {
jest.useFakeTimers({
doNotFake: [
Expand Down
58 changes: 15 additions & 43 deletions src/lib/services/graphql/graphql.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { GraphqlResponse } from '../../model/graphql-response.js';
import { calculateRandomizedExponentialBackoffTime } from '../../util/randomized-exponential-backoff/randomized-exponential-backoff.js';
import { sleepMs } from '../../util/sleep-ms/sleep-ms.js';
import { ApiBase, GraphqlQuery } from '../api-base/api-base.js';
import { RequestInit } from '../../model/fetch.js';
import { TemporaryApiKeyService } from '../temporary-api-key/temporary-api-key.service';

type QueryOrDocument = string | DocumentNode;

Expand Down Expand Up @@ -85,7 +85,6 @@ const CLIENT_SIDE_CLOSE_EVENT = 1000;
* trying to import GraphQLService.
*/
export class GraphqlService {
private apiKey: string;
private apiServer: string;

private socket: WebSocket = null;
Expand All @@ -98,6 +97,7 @@ export class GraphqlService {
private subscriptions: Subscription[] = [];
private subscriptionObserverMap: { [id: string]: Observer<object> } = {};
private subscriptionConnection$: Observable<ConnectionStatus>;
private temporaryApiKeyService: TemporaryApiKeyService | undefined;

private pongTimeout: any;
private pingPongInterval: any;
Expand Down Expand Up @@ -237,7 +237,10 @@ export class GraphqlService {
* @hidden
*/
setKey(apiKey: string) {
this.apiKey = apiKey;
this.temporaryApiKeyService = new TemporaryApiKeyService(
this.apiServer,
apiKey,
);
}

/**
Expand Down Expand Up @@ -278,48 +281,17 @@ export class GraphqlService {
}
this.setConnectionStatus(ConnectionStatus.CONNECTING);
console.info('[Qminder API]: Connecting to websocket');
this.fetchTemporaryApiKey().then((temporaryApiKey: string) => {
this.createSocketConnection(temporaryApiKey);
});
this.fetchTemporaryApiKey()
.then((temporaryApiKey: string) => {
this.createSocketConnection(temporaryApiKey);
})
.catch((e) => {
throw e;
});
}

private async fetchTemporaryApiKey(retryCount = 0): Promise<string> {
const url = 'graphql/connection-key';
const body: RequestInit = {
method: 'POST',
mode: 'cors',
headers: {
'X-Qminder-REST-API-Key': this.apiKey,
},
};

try {
const response = await fetch(`https://${this.apiServer}/${url}`, body);
const responseJson = await response.json();
return responseJson.key;
} catch (e) {
const timeOut = Math.min(60000, Math.max(5000, 2 ** retryCount * 1000));
if (this.isBrowserOnline()) {
console.warn(
`[Qminder API]: Failed fetching temporary API key! Retrying in ${
timeOut / 1000
} seconds`,
e,
);
} else {
console.info(
`[Qminder API]: Failed fetching temporary API key! We are offline. Retrying in ${
timeOut / 1000
} seconds`,
);
}
return new Promise((resolve) =>
setTimeout(
() => resolve(this.fetchTemporaryApiKey(retryCount + 1)),
timeOut,
),
);
}
private async fetchTemporaryApiKey(): Promise<string> {
return this.temporaryApiKeyService.fetchTemporaryApiKey();
}

private getServerUrl(temporaryApiKey: string): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { TemporaryApiKeyService } from './temporary-api-key.service';
import fetchMock from 'jest-fetch-mock';

jest.mock('../../util/sleep-ms/sleep-ms', () => ({
sleepMs: () => new Promise((resolve) => setTimeout(resolve, 4)),
}));

describe('Temporary API Key Service', function () {
const service = new TemporaryApiKeyService('api.qminder.com', 'initialkey');

beforeEach(async () => {
fetchMock.enableMocks();

jest.spyOn(global.console, 'info').mockImplementation();
jest.spyOn(global.console, 'error').mockImplementation();
});

afterEach(async () => {
fetchMock.mockRestore();
});

it('fetches temporary API key', () => {
service.fetchTemporaryApiKey();

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://api.qminder.com/graphql/connection-key',
{
headers: { 'X-Qminder-REST-API-Key': 'initialkey' },
method: 'POST',
mode: 'cors',
},
);
});

it('does not try again when server responds with 403', async () => {
fetchMock.mockResponseOnce('{}', {
status: 403,
statusText: 'I Dont know you!',
});
await expect(
async () => await service.fetchTemporaryApiKey(),
).rejects.toThrow();
});

it('tries again when server responds with 5XX error', async () => {
fetchMock.mockResponses(
['{}', { status: 500, statusText: 'Internal Server Error' }],
[JSON.stringify({ key: '12345' }), { status: 200 }],
[JSON.stringify({ key: '12345' }), { status: 200 }],
);
await service.fetchTemporaryApiKey();
});

it('tries again when response does not contain key', async () => {
fetchMock.mockResponses(
['{}', { status: 200 }],
[JSON.stringify({ key: '12345' }), { status: 200 }],
);
await service.fetchTemporaryApiKey();
});
});
91 changes: 91 additions & 0 deletions src/lib/services/temporary-api-key/temporary-api-key.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { RequestInit } from '../../model/fetch';
import { sleepMs } from '../../util/sleep-ms/sleep-ms';

export class TemporaryApiKeyService {
private readonly apiServer: string;
private readonly permanentApiKey: string;

constructor(apiServer: string, permanentApiKey: string) {
this.apiServer = apiServer;
this.permanentApiKey = permanentApiKey;
}

async fetchTemporaryApiKey(retryCount = 0): Promise<string> {
const url = 'graphql/connection-key';
const body: RequestInit = {
method: 'POST',
mode: 'cors',
headers: {
'X-Qminder-REST-API-Key': this.permanentApiKey,
},
};

let response: Response;

try {
response = await fetch(`https://${this.apiServer}/${url}`, body);
} catch (e) {
if (this.isBrowserOnline()) {
console.warn('[Qminder API]: Failed to fetch temporary API key');
} else {
console.info(
'[Qminder API]: Failed to fetch temporary API key. The browser is offline.',
);
}
return this.retry(retryCount + 1);
}

if (response.status === 403) {
throw new Error(
'Provided API key is invalid. Unable to fetch temporary key',
);
}
if (response.status >= 500) {
console.error(
`Failed to fetch API key from the server. Status: ${response.status}`,
);
return this.retry(retryCount + 1);
}

try {
const responseJson = await response.json();
const key = responseJson.key;
if (typeof key === 'undefined') {
throw new Error(
`Response does not contain key. Response: ${JSON.stringify(
responseJson,
)}`,
);
}
} catch (e) {
console.error(
'[Qminder API]: Failed to parse the temporary API key response',
e,
);
return this.retry(retryCount + 1);
}
}

private async retry(retryCount = 0): Promise<string> {
const timeOutMs = Math.min(60000, Math.max(5000, 2 ** retryCount * 1000));
const timeOutSec = timeOutMs / 1000;
console.info(
`[Qminder API]: Retrying to fetch API key in ${
timeOutSec / 1000
} seconds`,
);
await sleepMs(timeOutMs);
return this.fetchTemporaryApiKey(retryCount + 1);
}

/**
* Returns the online status of the browser.
* In the non-browser environment (NodeJS) this always returns true.
*/
private isBrowserOnline(): boolean {
if (typeof navigator === 'undefined') {
return true;
}
return navigator.onLine;
}
}
Loading