Skip to content

Commit 131d749

Browse files
committed
feat: support GraphQL for PactV3
1 parent 4d78c65 commit 131d749

File tree

8 files changed

+279
-56
lines changed

8 files changed

+279
-56
lines changed

examples/graphql/src/consumer.spec.ts

+30-35
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@ import * as chai from 'chai';
33
import * as path from 'path';
44
import * as chaiAsPromised from 'chai-as-promised';
55
import { query } from './consumer';
6-
import {
7-
Pact,
8-
GraphQLInteraction,
9-
Matchers,
10-
LogLevel,
11-
} from '@pact-foundation/pact';
6+
import { Matchers, LogLevel, GraphQLPactV3 } from '@pact-foundation/pact';
127
const { like } = Matchers;
138
const LOG_LEVEL = process.env.LOG_LEVEL || 'TRACE';
149

@@ -17,37 +12,39 @@ const expect = chai.expect;
1712
chai.use(chaiAsPromised);
1813

1914
describe('GraphQL example', () => {
20-
const provider = new Pact({
15+
const provider = new GraphQLPactV3({
2116
port: 4000,
22-
log: path.resolve(process.cwd(), 'logs', 'mockserver-integration.log'),
2317
dir: path.resolve(process.cwd(), 'pacts'),
2418
consumer: 'GraphQLConsumer',
2519
provider: 'GraphQLProvider',
2620
logLevel: LOG_LEVEL as LogLevel,
2721
});
2822

29-
before(() => provider.setup());
30-
after(() => provider.finalize());
31-
32-
describe('query hello on /graphql', () => {
23+
describe('When the "hello" query on /graphql is made', () => {
3324
before(() => {
34-
const graphqlQuery = new GraphQLInteraction()
35-
.uponReceiving('a hello request')
36-
.withQuery(
37-
`
38-
query HelloQuery {
39-
hello
40-
}
41-
`
25+
provider
26+
.given('the world exists')
27+
.withRequestBinaryFile(
28+
{ method: 'POST', path: '/projects/1001/images' },
29+
'',
30+
''
4231
)
43-
.withOperation('HelloQuery')
44-
.withRequest({
45-
path: '/graphql',
46-
method: 'POST',
47-
})
48-
.withVariables({
49-
foo: 'bar',
50-
})
32+
.uponReceiving('a hello request')
33+
// .withQuery(
34+
// `
35+
// query HelloQuery {
36+
// hello
37+
// }
38+
// `
39+
// )
40+
// .withOperation('HelloQuery')
41+
// .withRequest({
42+
// path: '/graphql',
43+
// method: 'POST',
44+
// })
45+
// .withVariables({
46+
// foo: 'bar',
47+
// })
5148
.willRespondWith({
5249
status: 200,
5350
headers: {
@@ -59,16 +56,14 @@ describe('GraphQL example', () => {
5956
},
6057
},
6158
});
62-
return provider.addInteraction(graphqlQuery);
6359
});
6460

65-
it('returns the correct response', () => {
66-
return expect(query()).to.eventually.deep.equal({
67-
hello: 'Hello world!',
61+
it('returns the correct response', async () => {
62+
await provider.executeTest(async () => {
63+
return expect(query()).to.eventually.deep.equal({
64+
hello: 'Hello world!',
65+
});
6866
});
6967
});
70-
71-
// verify with Pact, and reset expectations
72-
afterEach(() => provider.verify());
7368
});
7469
});

examples/graphql/src/provider.spec.ts

+27-21
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Verifier, LogLevel } from '@pact-foundation/pact';
22
import { versionFromGitTag } from 'absolute-version';
33
import app from './provider';
4+
import path = require('path');
45
const LOG_LEVEL = process.env.LOG_LEVEL || 'TRACE';
56

67
let server: any;
@@ -17,33 +18,38 @@ describe('Pact Verification', () => {
1718
// lexical binding required here
1819
const opts = {
1920
// Local pacts
20-
// pactUrls: [path.resolve(process.cwd(), "./pacts/graphqlconsumer-graphqlprovider.json")],
21-
pactBrokerUrl: 'https://test.pactflow.io/',
22-
pactBrokerUsername:
23-
process.env.PACT_BROKER_USERNAME || 'dXfltyFMgNOFZAxr8io9wJ37iUpY42M',
24-
pactBrokerPassword:
25-
process.env.PACT_BROKER_PASSWORD || 'O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1',
26-
provider: 'GraphQLProvider',
21+
pactUrls: [
22+
path.resolve(
23+
process.cwd(),
24+
'./pacts/graphqlconsumer-graphqlprovider.json'
25+
),
26+
],
27+
// pactBrokerUrl: 'https://test.pactflow.io/',
28+
// pactBrokerUsername:
29+
// process.env.PACT_BROKER_USERNAME || 'dXfltyFMgNOFZAxr8io9wJ37iUpY42M',
30+
// pactBrokerPassword:
31+
// process.env.PACT_BROKER_PASSWORD || 'O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1',
32+
// provider: 'GraphQLProvider',
2733
providerBaseUrl: 'http://localhost:4000/graphql',
2834
// Your version numbers need to be unique for every different version of your provider
2935
// see https://docs.pact.io/getting_started/versioning_in_the_pact_broker/ for details.
3036
// If you use git tags, then you can use absolute-version as we do here.
31-
providerVersion: versionFromGitTag(),
32-
publishVerificationResult: true,
33-
providerVersionBranch: process.env.GIT_BRANCH || 'master',
37+
// providerVersion: versionFromGitTag(),
38+
// publishVerificationResult: true,
39+
// providerVersionBranch: process.env.GIT_BRANCH || 'master',
3440

3541
// Find _all_ pacts that match the current provider branch
36-
consumerVersionSelectors: [
37-
{
38-
matchingBranch: true,
39-
},
40-
{
41-
mainBranch: true,
42-
},
43-
{
44-
deployedOrReleased: true,
45-
},
46-
],
42+
// consumerVersionSelectors: [
43+
// {
44+
// matchingBranch: true,
45+
// },
46+
// {
47+
// mainBranch: true,
48+
// },
49+
// {
50+
// deployedOrReleased: true,
51+
// },
52+
// ],
4753
logLevel: LOG_LEVEL as LogLevel,
4854
};
4955

src/v3/graphql/configurationError.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class ConfigurationError extends Error {}

src/v3/graphql/graphQL.ts

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { gql } from 'graphql-tag';
2+
import { ASTNode, print } from 'graphql';
3+
import { isUndefined } from 'lodash';
4+
import { reject } from 'ramda';
5+
6+
import { ConfigurationError } from './configurationError';
7+
import { GraphQLQueryError } from './graphQLQueryError';
8+
import { PactV3 } from '../pact';
9+
import { GraphQLVariables } from '../../dsl/graphql';
10+
import { V3Request, V3Response } from '../types';
11+
import { OperationType } from './types';
12+
import { JsonMap } from '../../common/jsonTypes';
13+
14+
import { regex } from '../matchers';
15+
16+
const escapeSpace = (s: string) => s.replace(/\s+/g, '\\s*');
17+
18+
const escapeRegexChars = (s: string) =>
19+
s.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
20+
21+
const escapeGraphQlQuery = (s: string) => escapeSpace(escapeRegexChars(s));
22+
23+
/**
24+
* Accepts a raw or pre-parsed query, validating in the former case, and
25+
* returns a normalized raw query.
26+
* @param query {string|ASTNode} the query to validate
27+
* @param type the operation type
28+
*/
29+
function validateQuery(query: string | ASTNode, type: OperationType): string {
30+
if (!query) {
31+
throw new ConfigurationError(`You must provide a GraphQL ${type}.`);
32+
}
33+
34+
if (typeof query !== 'string') {
35+
if (query?.kind === 'Document') {
36+
// Already parsed, store in string form
37+
return print(query);
38+
}
39+
throw new ConfigurationError(
40+
'You must provide a either a string or parsed GraphQL.'
41+
);
42+
} else {
43+
// String, so validate it
44+
try {
45+
gql(query);
46+
} catch (e) {
47+
throw new GraphQLQueryError(`GraphQL ${type} is invalid: ${e.message}`);
48+
}
49+
50+
return query;
51+
}
52+
}
53+
54+
/**
55+
* Expose a V3 compatible GraphQL interface
56+
*
57+
* Code borrowed/inspired from https://gist.github.com/wabrit/2d1e1f9520aa133908f0a3716338e5ff
58+
*/
59+
export class GraphQLPactV3 extends PactV3 {
60+
private operation?: string = undefined;
61+
62+
private variables?: GraphQLVariables = undefined;
63+
64+
private query: string;
65+
66+
private req?: V3Request = undefined;
67+
68+
public given(providerState: string, parameters?: JsonMap): GraphQLPactV3 {
69+
super.given(providerState, parameters);
70+
71+
return this;
72+
}
73+
74+
public uponReceiving(description: string): GraphQLPactV3 {
75+
super.uponReceiving(description);
76+
77+
return this;
78+
}
79+
80+
/**
81+
* The GraphQL operation name, if used.
82+
* @param operation {string} the name of the operation
83+
* @return this object
84+
*/
85+
withOperation(operation: string): GraphQLPactV3 {
86+
this.operation = operation;
87+
return this;
88+
}
89+
90+
/**
91+
* Add variables used in the Query.
92+
* @param variables {GraphQLVariables}
93+
* @return this object
94+
*/
95+
withVariables(variables: GraphQLVariables): GraphQLPactV3 {
96+
this.variables = variables;
97+
return this;
98+
}
99+
100+
/**
101+
* The actual GraphQL query as a string.
102+
*
103+
* NOTE: spaces are not important, Pact will auto-generate a space-insensitive matcher
104+
*
105+
* e.g. the value for the "query" field in the GraphQL HTTP payload:
106+
* '{ "query": "{
107+
* Category(id:7) {
108+
* id,
109+
* name,
110+
* subcategories {
111+
* id,
112+
* name
113+
* }
114+
* }
115+
* }"
116+
* }'
117+
* @param query {string|ASTNode} parsed or unparsed query
118+
* @return this object
119+
*/
120+
withQuery(query: string | ASTNode): GraphQLPactV3 {
121+
this.query = validateQuery(query, OperationType.Query);
122+
123+
return this;
124+
}
125+
126+
/**
127+
* The actual GraphQL mutation as a string or parse tree.
128+
*
129+
* NOTE: spaces are not important, Pact will auto-generate a space-insensitive matcher
130+
*
131+
* e.g. the value for the "query" field in the GraphQL HTTP payload:
132+
*
133+
* mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
134+
* createReview(episode: $ep, review: $review) {
135+
* stars
136+
* commentary
137+
* }
138+
* }
139+
* @param mutation {string|ASTNode} parsed or unparsed mutation
140+
* @return this object
141+
*/
142+
withMutation(mutation: string | ASTNode): GraphQLPactV3 {
143+
this.query = validateQuery(mutation, OperationType.Mutation);
144+
145+
return this;
146+
}
147+
148+
/**
149+
* Used to pass in the method, path and content-type; the body detail would
150+
* not typically be passed here as that will be internally constructed from
151+
* withQuery/withMutation/withVariables calls.
152+
*
153+
* @see {@link withQuery}
154+
* @see {@link withMutation}
155+
* @see {@link withVariables}
156+
* @param req {V3Request} request
157+
* @return this object
158+
*/
159+
withRequest(req: V3Request): GraphQLPactV3 {
160+
// Just take what we need from the request, as most of the detail will
161+
// come from withQuery/withMutation/withVariables
162+
this.req = req;
163+
return this;
164+
}
165+
166+
/**
167+
* Overridden as this is the "trigger point" by which we should have received all
168+
* request information.
169+
* @param res {V3Response} the expected response
170+
* @returns this object
171+
*/
172+
willRespondWith(res: V3Response): GraphQLPactV3 {
173+
if (!this.query) {
174+
throw new ConfigurationError('You must provide a GraphQL query.');
175+
}
176+
177+
if (!this.req) {
178+
throw new ConfigurationError('You must provide a GraphQL request.');
179+
}
180+
181+
this.req = {
182+
...this.req,
183+
body: reject(isUndefined, {
184+
operationName: this.operation,
185+
query: regex(escapeGraphQlQuery(this.query), this.query),
186+
variables: this.variables,
187+
}),
188+
headers: {
189+
'Content-Type': (this.req.contentType ||= 'application/json'),
190+
},
191+
method: (this.req.method ||= 'POST'),
192+
};
193+
194+
super.withRequest(this.req);
195+
super.willRespondWith(res);
196+
return this;
197+
}
198+
199+
public addInteraction(): GraphQLPactV3 {
200+
throw new ConfigurationError('Only GraphQL Queries are allowed');
201+
}
202+
203+
public withRequestBinaryFile(): PactV3 {
204+
throw new ConfigurationError('Only GraphQL Queries are allowed');
205+
}
206+
207+
public withRequestMultipartFileUpload(): PactV3 {
208+
throw new ConfigurationError('Only GraphQL Queries are allowed');
209+
}
210+
}

src/v3/graphql/graphQLQueryError.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class GraphQLQueryError extends Error {}

src/v3/graphql/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './graphQL';
2+
export * from './configurationError';
3+
export * from './graphQLQueryError';
4+
export * from './types';

src/v3/graphql/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum OperationType {
2+
Mutation = 'Mutation',
3+
Query = 'Query',
4+
}

src/v3/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ export * from './xml/xmlBuilder';
1717
export * from './xml/xmlElement';
1818
export * from './xml/xmlNode';
1919
export * from './xml/xmlText';
20+
21+
export * from './graphql';

0 commit comments

Comments
 (0)