Skip to content

Commit 9e78f37

Browse files
authored
feat(graphql): query verifies the response (#767)
* feat(graphql): query verifies the response * Fixed tests for API base * More tests * Refactor * Updated tests * Clearer GraphqlResponse model * Fixed the model * Fixed tests
1 parent 3e64389 commit 9e78f37

File tree

8 files changed

+107
-75
lines changed

8 files changed

+107
-75
lines changed

packages/example-graphql-events/app.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { Qminder } from 'qminder-api';
2+
import gql from 'graphql-tag';
3+
4+
interface LocationsResponse {
5+
locations: Location[];
6+
}
27

38
interface Location {
49
id: string;
@@ -16,25 +21,26 @@ interface TicketCreatedEvent {
1621
}
1722

1823
async function findFirstLocationId(): Promise<Location> {
19-
const result: any = await Qminder.GraphQL.query(`{
20-
locations {
24+
let result: LocationsResponse;
25+
try {
26+
result = await Qminder.GraphQL.query(gql`
27+
{
28+
locations {
2129
id
2230
name
31+
}
2332
}
24-
}`);
25-
26-
if (result.errors) {
27-
throw new Error(
28-
`Failed to find locations. Errors: ${JSON.stringify(result.errors)}`,
29-
);
33+
`);
34+
} catch (e) {
35+
throw new Error(`Failed to find locations. Error: ${JSON.stringify(e)}`);
3036
}
3137

32-
if (result.data.locations.length < 1) {
38+
if (result.locations.length < 1) {
3339
throw new Error('Account does not have any locations');
3440
}
3541

36-
console.log(`Found ${result.data.locations.length} locations`);
37-
return result.data.locations[0];
42+
console.log(`Found ${result.locations.length} locations`);
43+
return result.locations[0];
3844
}
3945

4046
async function listenForTickets() {

packages/example-graphql-events/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@
1313
"typescript": "5.7.2"
1414
},
1515
"author": "Qminder <support@qminder.com> (https://www.qminder.com)",
16-
"license": "Apache-2.0"
16+
"license": "Apache-2.0",
17+
"dependencies": {
18+
"graphql-tag": "^2.12.6"
19+
}
1720
}
Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
export interface GraphqlResponse {
2-
/** An array that contains any GraphQL errors. */
3-
errors?: GraphqlError[];
4-
/** If the data was loaded without any errors, contains the requested object. */
5-
data?: object;
1+
export type GraphqlResponse<T> = SuccessResponse<T> | ErrorResponse;
2+
3+
export interface SuccessResponse<T> {
4+
data: T;
5+
}
6+
7+
export interface ErrorResponse {
8+
errors: GraphqlError[];
69
}
710

8-
interface GraphqlError {
11+
export interface GraphqlError {
912
message: string;
1013
errorType: string;
1114
validationErrorType?: string;
@@ -14,3 +17,15 @@ interface GraphqlError {
1417
extensions?: any;
1518
locations: { line: number; column: number; sourceName: string }[];
1619
}
20+
21+
export function isErrorResponse<T>(
22+
response: GraphqlResponse<T>,
23+
): response is ErrorResponse {
24+
return Object.prototype.hasOwnProperty.call(response, 'errors');
25+
}
26+
27+
export function isSuccessResponse<T>(
28+
response: GraphqlResponse<T>,
29+
): response is SuccessResponse<T> {
30+
return Object.prototype.hasOwnProperty.call(response, 'data');
31+
}

packages/javascript-api/src/lib/services/api-base/api-base.spec.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ComplexError } from '../../model/errors/complex-error';
77
import { SimpleError } from '../../model/errors/simple-error';
88
import { UnknownError } from '../../model/errors/unknown-error';
99
import { Qminder } from '../../qminder';
10+
import { ResponseValidationError } from '../../model/errors/response-validation-error';
1011

1112
/**
1213
* A function that generates an object with the following keys:
@@ -37,8 +38,6 @@ function generateRequestData(query: string, responseData: any): any {
3738
body: JSON.stringify(queryObject),
3839
},
3940
successfulResponse: {
40-
statusCode: 200,
41-
errors: [],
4241
data: [responseData],
4342
},
4443
};
@@ -47,7 +46,7 @@ function generateRequestData(query: string, responseData: any): any {
4746
const FAKE_RESPONSE = {
4847
ok: true,
4948
json() {
50-
return { statusCode: 200 };
49+
return {};
5150
},
5251
};
5352

@@ -436,7 +435,7 @@ describe('ApiBase', () => {
436435

437436
it('throws when no query is passed', () => {
438437
Qminder.ApiBase.setKey('testing');
439-
expect(() => (Qminder.ApiBase.queryGraph as any)()).toThrow();
438+
expect(() => (Qminder.ApiBase.queryGraph as any)()).rejects.toThrow();
440439
});
441440

442441
it('does not throw when no variables are passed', async () => {
@@ -452,7 +451,7 @@ describe('ApiBase', () => {
452451

453452
it('throws when API key is not defined', () => {
454453
fetchSpy.mockReturnValue(new MockResponse(ME_ID.successfulResponse));
455-
expect(() => Qminder.ApiBase.queryGraph(ME_ID.request)).toThrow();
454+
expect(() => Qminder.ApiBase.queryGraph(ME_ID.request)).rejects.toThrow();
456455
});
457456

458457
it('sends a correct request', () => {
@@ -464,42 +463,43 @@ describe('ApiBase', () => {
464463
expect(fetchSpy).toHaveBeenCalledWith(API_URL, ME_ID.expectedFetch);
465464
});
466465

467-
it('resolves with the entire response object, not only response data', (done) => {
466+
it('resolves with response data', (done) => {
468467
Qminder.ApiBase.setKey('testing');
469468
fetchSpy.mockImplementation(() =>
470469
Promise.resolve(new MockResponse(ME_ID.successfulResponse)),
471470
);
472471

473472
Qminder.ApiBase.queryGraph(ME_ID.request).then((response) => {
474-
expect(response).toEqual(ME_ID.successfulResponse);
473+
expect(response).toEqual(ME_ID.successfulResponse.data);
475474
done();
476475
});
477476
});
478477

479-
it('throws an error when getting errors as response', (done) => {
478+
it('throws an error when getting errors as response', async () => {
480479
Qminder.ApiBase.setKey('testing');
481480
fetchSpy.mockImplementation(() =>
482481
Promise.resolve(new MockResponse(ERROR_UNDEFINED_FIELD)),
483482
);
484483

485-
Qminder.ApiBase.queryGraph(ME_ID.request).then(
486-
() => done(new Error('QueryGraph should have thrown an error')),
487-
() => done(),
484+
expect(async () => {
485+
await Qminder.ApiBase.queryGraph(ME_ID.request);
486+
}).rejects.toThrow(
487+
new SimpleError(
488+
"Validation error of type FieldUndefined: Field 'x' in type 'Account' is undefined @ 'account/x'",
489+
),
488490
);
489491
});
490492

491-
it('should resolve with response, even if response has errors', (done) => {
493+
it('should throw an error when response does not contain any data', async () => {
492494
Qminder.ApiBase.setKey('testing');
493-
fetchSpy.mockImplementation(() =>
494-
Promise.resolve(new MockResponse(ERROR_UNDEFINED_FIELD)),
495-
);
496-
497-
Qminder.ApiBase.queryGraph(ME_ID.request).then(
498-
() => done(new Error('Should have errored')),
499-
(error: SimpleError) => {
500-
expect(error.message).toEqual(VALIDATION_ERROR);
501-
done();
502-
},
495+
fetchSpy.mockImplementation(() => Promise.resolve(FAKE_RESPONSE));
496+
497+
expect(async () => {
498+
await Qminder.ApiBase.queryGraph(ME_ID.request);
499+
}).rejects.toThrow(
500+
new ResponseValidationError(
501+
`Server response is not valid GraphQL response. Response: {}`,
502+
),
503503
);
504504
});
505505
});

packages/javascript-api/src/lib/services/api-base/api-base.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { GraphQLError } from 'graphql';
21
import { ComplexError } from '../../model/errors/complex-error.js';
32
import { SimpleError } from '../../model/errors/simple-error.js';
43
import { UnknownError } from '../../model/errors/unknown-error.js';
5-
import { GraphqlResponse } from '../../model/graphql-response.js';
4+
import {
5+
ErrorResponse,
6+
GraphqlResponse,
7+
isErrorResponse,
8+
isSuccessResponse,
9+
} from '../../model/graphql-response.js';
610
import { RequestInit } from '../../model/fetch.js';
11+
import { ResponseValidationError } from '../../model/errors/response-validation-error.js';
712

813
type RequestInitWithMethodRequired = Pick<RequestInit, 'method' | 'headers'> & {
914
body?: string | File | object;
@@ -121,7 +126,7 @@ export class ApiBase {
121126
* @throws when the API key is missing or invalid, or when errors in the
122127
* response are found
123128
*/
124-
static queryGraph(query: GraphqlQuery): Promise<GraphqlResponse> {
129+
static async queryGraph<T>(query: GraphqlQuery): Promise<T> {
125130
if (!this.apiKey) {
126131
throw new Error('Please set the API key before making any requests.');
127132
}
@@ -136,17 +141,21 @@ export class ApiBase {
136141
body: JSON.stringify(query),
137142
};
138143

139-
return fetch(`https://${this.apiServer}/graphql`, init)
140-
.then((response: Response) => response.json())
141-
.then((responseJson: any) => {
142-
if (responseJson.errorMessage) {
143-
throw new Error(responseJson.errorMessage);
144-
}
145-
if (responseJson.errors && responseJson.errors.length > 0) {
146-
throw this.extractGraphQLError(responseJson);
147-
}
148-
return responseJson as Promise<GraphqlResponse>;
149-
});
144+
let response = await fetch(`https://${this.apiServer}/graphql`, init);
145+
let graphQLResponse: GraphqlResponse<T> = await response.json();
146+
147+
if (isErrorResponse(graphQLResponse)) {
148+
throw this.extractGraphQLError(graphQLResponse);
149+
}
150+
if (isSuccessResponse(graphQLResponse)) {
151+
return graphQLResponse.data;
152+
}
153+
154+
throw new ResponseValidationError(
155+
`Server response is not valid GraphQL response. Response: ${JSON.stringify(
156+
graphQLResponse,
157+
)}`,
158+
);
150159
}
151160

152161
private static extractError(response: any): Error {
@@ -169,9 +178,7 @@ export class ApiBase {
169178
return new UnknownError();
170179
}
171180

172-
private static extractGraphQLError(response: {
173-
errors: GraphQLError[];
174-
}): Error {
181+
private static extractGraphQLError(response: ErrorResponse): Error {
175182
return new SimpleError(
176183
response.errors.map((error) => error.message).join('\n'),
177184
);

packages/javascript-api/src/lib/services/graphql/__tests__/graphql.service.spec.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import { GraphqlService } from '../graphql.service';
88
jest.mock('isomorphic-ws', () => jest.fn());
99

1010
describe('GraphQL service', function () {
11-
const ME_ID_REQUEST = '{ me { id } }';
11+
const ME_ID_REQUEST = gql`
12+
{
13+
me {
14+
id
15+
}
16+
}
17+
`;
18+
const ME_ID_REQUEST_STRING = '{ me { id }\n}';
1219
const ME_ID_SUCCESS_RESPONSE: any = {
1320
statusCode: 200,
1421
data: [
@@ -45,25 +52,25 @@ describe('GraphQL service', function () {
4552
});
4653
it('calls ApiBase.queryGraph with the correct parameters', async () => {
4754
await graphqlService.query(ME_ID_REQUEST);
48-
const graphqlQuery = { query: ME_ID_REQUEST };
55+
const graphqlQuery = { query: ME_ID_REQUEST_STRING };
4956
expect(requestStub.calledWith(graphqlQuery)).toBeTruthy();
5057
});
5158
it('calls ApiBase.queryGraph with both query & variables', async () => {
5259
const variables = { x: 5, y: 4 };
5360
await graphqlService.query(ME_ID_REQUEST, variables);
54-
const graphqlQuery = { query: ME_ID_REQUEST, variables };
61+
const graphqlQuery = { query: ME_ID_REQUEST_STRING, variables };
5562
expect(requestStub.calledWith(graphqlQuery)).toBeTruthy();
5663
});
5764
it('collapses whitespace and newlines', async () => {
58-
const query = `
65+
const query = gql`
5966
{
6067
me {
6168
id
6269
}
6370
}
6471
`;
6572
await graphqlService.query(query);
66-
const graphqlQuery = { query: ME_ID_REQUEST };
73+
const graphqlQuery = { query: ME_ID_REQUEST_STRING };
6774
expect(requestStub.calledWith(graphqlQuery)).toBeTruthy();
6875
});
6976

packages/javascript-api/src/lib/services/graphql/graphql.service.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import WebSocket, { CloseEvent } from 'isomorphic-ws';
88
import { Observable, Observer, startWith, Subject } from 'rxjs';
99
import { distinctUntilChanged, shareReplay } from 'rxjs/operators';
1010
import { ConnectionStatus } from '../../model/connection-status.js';
11-
import { GraphqlResponse } from '../../model/graphql-response.js';
1211
import { calculateRandomizedExponentialBackoffTime } from '../../util/randomized-exponential-backoff/randomized-exponential-backoff.js';
1312
import { sleepMs } from '../../util/sleep-ms/sleep-ms.js';
1413
import { ApiBase, GraphqlQuery } from '../api-base/api-base.js';
@@ -146,22 +145,16 @@ export class GraphqlService {
146145
* });
147146
* ```
148147
*
149-
* @param query required: the query to send, for example `"{ me { selectedLocation } }"`
148+
* @param queryDocument required: the query to send, for example gql`{ me { selectedLocation } }`
150149
* @param variables optional: additional variables for the query, if variables were used
151150
* @returns a promise that resolves to the query's results, or rejects if the query failed
152151
* @throws when the 'query' argument is undefined or an empty string
153152
*/
154-
query(
155-
queryDocument: QueryOrDocument,
153+
query<T>(
154+
queryDocument: DocumentNode,
156155
variables?: { [key: string]: any },
157-
): Promise<GraphqlResponse> {
158-
const query = queryToString(queryDocument);
159-
if (!query || query.length === 0) {
160-
throw new Error(
161-
'GraphQLService query expects a GraphQL query as its first argument',
162-
);
163-
}
164-
156+
): Promise<T> {
157+
const query = print(queryDocument);
165158
const packedQuery = query.replace(/\s\s+/g, ' ').trim();
166159
const graphqlQuery: GraphqlQuery = {
167160
query: packedQuery,

yarn.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4562,7 +4562,7 @@ __metadata:
45624562
languageName: node
45634563
linkType: hard
45644564

4565-
"graphql-tag@npm:2.12.6":
4565+
"graphql-tag@npm:2.12.6, graphql-tag@npm:^2.12.6":
45664566
version: 2.12.6
45674567
resolution: "graphql-tag@npm:2.12.6"
45684568
dependencies:
@@ -6767,6 +6767,7 @@ __metadata:
67676767
version: 0.0.0-use.local
67686768
resolution: "qminder-graphql-events-example@workspace:packages/example-graphql-events"
67696769
dependencies:
6770+
graphql-tag: "npm:^2.12.6"
67706771
tsx: "npm:^4.19.2"
67716772
typescript: "npm:5.7.2"
67726773
languageName: unknown

0 commit comments

Comments
 (0)