From 22d8abb936cd65331315e6161eb401be55316de2 Mon Sep 17 00:00:00 2001 From: mefellows Date: Sat, 6 Jul 2024 23:48:36 +1000 Subject: [PATCH] feat: support GraphQL for PactV3 Fixes #1093 --- examples/graphql/src/consumer.spec.ts | 30 ++-- examples/graphql/src/provider.spec.ts | 48 +++--- src/v3/graphql/configurationError.ts | 1 + src/v3/graphql/graphQL.ts | 210 ++++++++++++++++++++++++++ src/v3/graphql/graphQLQueryError.ts | 1 + src/v3/graphql/index.ts | 4 + src/v3/graphql/types.ts | 4 + src/v3/index.ts | 2 + 8 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 src/v3/graphql/configurationError.ts create mode 100644 src/v3/graphql/graphQL.ts create mode 100644 src/v3/graphql/graphQLQueryError.ts create mode 100644 src/v3/graphql/index.ts create mode 100644 src/v3/graphql/types.ts diff --git a/examples/graphql/src/consumer.spec.ts b/examples/graphql/src/consumer.spec.ts index 4e707acce..defc039ed 100644 --- a/examples/graphql/src/consumer.spec.ts +++ b/examples/graphql/src/consumer.spec.ts @@ -3,12 +3,7 @@ import * as chai from 'chai'; import * as path from 'path'; import * as chaiAsPromised from 'chai-as-promised'; import { query } from './consumer'; -import { - Pact, - GraphQLInteraction, - Matchers, - LogLevel, -} from '@pact-foundation/pact'; +import { Matchers, LogLevel, GraphQLPactV3 } from '@pact-foundation/pact'; const { like } = Matchers; const LOG_LEVEL = process.env.LOG_LEVEL || 'TRACE'; @@ -17,21 +12,18 @@ const expect = chai.expect; chai.use(chaiAsPromised); describe('GraphQL example', () => { - const provider = new Pact({ + const provider = new GraphQLPactV3({ port: 4000, - log: path.resolve(process.cwd(), 'logs', 'mockserver-integration.log'), dir: path.resolve(process.cwd(), 'pacts'), consumer: 'GraphQLConsumer', provider: 'GraphQLProvider', logLevel: LOG_LEVEL as LogLevel, }); - before(() => provider.setup()); - after(() => provider.finalize()); - - describe('query hello on /graphql', () => { + describe('When the "hello" query on /graphql is made', () => { before(() => { - const graphqlQuery = new GraphQLInteraction() + provider + .given('the world exists') .uponReceiving('a hello request') .withQuery( ` @@ -59,16 +51,14 @@ describe('GraphQL example', () => { }, }, }); - return provider.addInteraction(graphqlQuery); }); - it('returns the correct response', () => { - return expect(query()).to.eventually.deep.equal({ - hello: 'Hello world!', + it('returns the correct response', async () => { + await provider.executeTest(async () => { + return expect(query()).to.eventually.deep.equal({ + hello: 'Hello world!', + }); }); }); - - // verify with Pact, and reset expectations - afterEach(() => provider.verify()); }); }); diff --git a/examples/graphql/src/provider.spec.ts b/examples/graphql/src/provider.spec.ts index 5e7b739be..31e71f88b 100644 --- a/examples/graphql/src/provider.spec.ts +++ b/examples/graphql/src/provider.spec.ts @@ -1,6 +1,7 @@ import { Verifier, LogLevel } from '@pact-foundation/pact'; import { versionFromGitTag } from 'absolute-version'; import app from './provider'; +import path = require('path'); const LOG_LEVEL = process.env.LOG_LEVEL || 'TRACE'; let server: any; @@ -17,33 +18,38 @@ describe('Pact Verification', () => { // lexical binding required here const opts = { // Local pacts - // pactUrls: [path.resolve(process.cwd(), "./pacts/graphqlconsumer-graphqlprovider.json")], - pactBrokerUrl: 'https://test.pactflow.io/', - pactBrokerUsername: - process.env.PACT_BROKER_USERNAME || 'dXfltyFMgNOFZAxr8io9wJ37iUpY42M', - pactBrokerPassword: - process.env.PACT_BROKER_PASSWORD || 'O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1', - provider: 'GraphQLProvider', + pactUrls: [ + path.resolve( + process.cwd(), + './pacts/graphqlconsumer-graphqlprovider.json' + ), + ], + // pactBrokerUrl: 'https://test.pactflow.io/', + // pactBrokerUsername: + // process.env.PACT_BROKER_USERNAME || 'dXfltyFMgNOFZAxr8io9wJ37iUpY42M', + // pactBrokerPassword: + // process.env.PACT_BROKER_PASSWORD || 'O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1', + // provider: 'GraphQLProvider', providerBaseUrl: 'http://localhost:4000/graphql', // Your version numbers need to be unique for every different version of your provider // see https://docs.pact.io/getting_started/versioning_in_the_pact_broker/ for details. // If you use git tags, then you can use absolute-version as we do here. - providerVersion: versionFromGitTag(), - publishVerificationResult: true, - providerVersionBranch: process.env.GIT_BRANCH || 'master', + // providerVersion: versionFromGitTag(), + // publishVerificationResult: true, + // providerVersionBranch: process.env.GIT_BRANCH || 'master', // Find _all_ pacts that match the current provider branch - consumerVersionSelectors: [ - { - matchingBranch: true, - }, - { - mainBranch: true, - }, - { - deployedOrReleased: true, - }, - ], + // consumerVersionSelectors: [ + // { + // matchingBranch: true, + // }, + // { + // mainBranch: true, + // }, + // { + // deployedOrReleased: true, + // }, + // ], logLevel: LOG_LEVEL as LogLevel, }; diff --git a/src/v3/graphql/configurationError.ts b/src/v3/graphql/configurationError.ts new file mode 100644 index 000000000..474db6449 --- /dev/null +++ b/src/v3/graphql/configurationError.ts @@ -0,0 +1 @@ +export class ConfigurationError extends Error {} diff --git a/src/v3/graphql/graphQL.ts b/src/v3/graphql/graphQL.ts new file mode 100644 index 000000000..7077b0624 --- /dev/null +++ b/src/v3/graphql/graphQL.ts @@ -0,0 +1,210 @@ +import { gql } from 'graphql-tag'; +import { ASTNode, print } from 'graphql'; +import { isUndefined } from 'lodash'; +import { reject } from 'ramda'; + +import { ConfigurationError } from './configurationError'; +import { GraphQLQueryError } from './graphQLQueryError'; +import { PactV3 } from '../pact'; +import { GraphQLVariables } from '../../dsl/graphql'; +import { V3Request, V3Response } from '../types'; +import { OperationType } from './types'; +import { JsonMap } from '../../common/jsonTypes'; + +import { regex } from '../matchers'; + +const escapeSpace = (s: string) => s.replace(/\s+/g, '\\s*'); + +const escapeRegexChars = (s: string) => + s.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + +const escapeGraphQlQuery = (s: string) => escapeSpace(escapeRegexChars(s)); + +/** + * Accepts a raw or pre-parsed query, validating in the former case, and + * returns a normalized raw query. + * @param query {string|ASTNode} the query to validate + * @param type the operation type + */ +function validateQuery(query: string | ASTNode, type: OperationType): string { + if (!query) { + throw new ConfigurationError(`You must provide a GraphQL ${type}.`); + } + + if (typeof query !== 'string') { + if (query?.kind === 'Document') { + // Already parsed, store in string form + return print(query); + } + throw new ConfigurationError( + 'You must provide a either a string or parsed GraphQL.' + ); + } else { + // String, so validate it + try { + gql(query); + } catch (e) { + throw new GraphQLQueryError(`GraphQL ${type} is invalid: ${e.message}`); + } + + return query; + } +} + +/** + * Expose a V3 compatible GraphQL interface + * + * Code borrowed/inspired from https://gist.github.com/wabrit/2d1e1f9520aa133908f0a3716338e5ff + */ +export class GraphQLPactV3 extends PactV3 { + private operation?: string = undefined; + + private variables?: GraphQLVariables = undefined; + + private query: string; + + private req?: V3Request = undefined; + + public given(providerState: string, parameters?: JsonMap): GraphQLPactV3 { + super.given(providerState, parameters); + + return this; + } + + public uponReceiving(description: string): GraphQLPactV3 { + super.uponReceiving(description); + + return this; + } + + /** + * The GraphQL operation name, if used. + * @param operation {string} the name of the operation + * @return this object + */ + withOperation(operation: string): GraphQLPactV3 { + this.operation = operation; + return this; + } + + /** + * Add variables used in the Query. + * @param variables {GraphQLVariables} + * @return this object + */ + withVariables(variables: GraphQLVariables): GraphQLPactV3 { + this.variables = variables; + return this; + } + + /** + * The actual GraphQL query as a string. + * + * NOTE: spaces are not important, Pact will auto-generate a space-insensitive matcher + * + * e.g. the value for the "query" field in the GraphQL HTTP payload: + * '{ "query": "{ + * Category(id:7) { + * id, + * name, + * subcategories { + * id, + * name + * } + * } + * }" + * }' + * @param query {string|ASTNode} parsed or unparsed query + * @return this object + */ + withQuery(query: string | ASTNode): GraphQLPactV3 { + this.query = validateQuery(query, OperationType.Query); + + return this; + } + + /** + * The actual GraphQL mutation as a string or parse tree. + * + * NOTE: spaces are not important, Pact will auto-generate a space-insensitive matcher + * + * e.g. the value for the "query" field in the GraphQL HTTP payload: + * + * mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) { + * createReview(episode: $ep, review: $review) { + * stars + * commentary + * } + * } + * @param mutation {string|ASTNode} parsed or unparsed mutation + * @return this object + */ + withMutation(mutation: string | ASTNode): GraphQLPactV3 { + this.query = validateQuery(mutation, OperationType.Mutation); + + return this; + } + + /** + * Used to pass in the method, path and content-type; the body detail would + * not typically be passed here as that will be internally constructed from + * withQuery/withMutation/withVariables calls. + * + * @see {@link withQuery} + * @see {@link withMutation} + * @see {@link withVariables} + * @param req {V3Request} request + * @return this object + */ + withRequest(req: V3Request): GraphQLPactV3 { + // Just take what we need from the request, as most of the detail will + // come from withQuery/withMutation/withVariables + this.req = req; + return this; + } + + /** + * Overridden as this is the "trigger point" by which we should have received all + * request information. + * @param res {V3Response} the expected response + * @returns this object + */ + willRespondWith(res: V3Response): GraphQLPactV3 { + if (!this.query) { + throw new ConfigurationError('You must provide a GraphQL query.'); + } + + if (!this.req) { + throw new ConfigurationError('You must provide a GraphQL request.'); + } + + this.req = { + ...this.req, + body: reject(isUndefined, { + operationName: this.operation, + query: regex(escapeGraphQlQuery(this.query), this.query), + variables: this.variables, + }), + headers: { + 'Content-Type': (this.req.contentType ||= 'application/json'), + }, + method: (this.req.method ||= 'POST'), + }; + + super.withRequest(this.req); + super.willRespondWith(res); + return this; + } + + public addInteraction(): GraphQLPactV3 { + throw new ConfigurationError('Only GraphQL Queries are allowed'); + } + + public withRequestBinaryFile(): PactV3 { + throw new ConfigurationError('Only GraphQL Queries are allowed'); + } + + public withRequestMultipartFileUpload(): PactV3 { + throw new ConfigurationError('Only GraphQL Queries are allowed'); + } +} diff --git a/src/v3/graphql/graphQLQueryError.ts b/src/v3/graphql/graphQLQueryError.ts new file mode 100644 index 000000000..3eab2b37d --- /dev/null +++ b/src/v3/graphql/graphQLQueryError.ts @@ -0,0 +1 @@ +export class GraphQLQueryError extends Error {} diff --git a/src/v3/graphql/index.ts b/src/v3/graphql/index.ts new file mode 100644 index 000000000..54cf7a591 --- /dev/null +++ b/src/v3/graphql/index.ts @@ -0,0 +1,4 @@ +export * from './graphQL'; +export * from './configurationError'; +export * from './graphQLQueryError'; +export * from './types'; diff --git a/src/v3/graphql/types.ts b/src/v3/graphql/types.ts new file mode 100644 index 000000000..4ebf705c4 --- /dev/null +++ b/src/v3/graphql/types.ts @@ -0,0 +1,4 @@ +export enum OperationType { + Mutation = 'Mutation', + Query = 'Query', +} diff --git a/src/v3/index.ts b/src/v3/index.ts index 16ab0cad1..444fa49e6 100644 --- a/src/v3/index.ts +++ b/src/v3/index.ts @@ -17,3 +17,5 @@ export * from './xml/xmlBuilder'; export * from './xml/xmlElement'; export * from './xml/xmlNode'; export * from './xml/xmlText'; + +export * from './graphql';