Skip to content

Commit

Permalink
feat: support V4 Pact interface (beta)
Browse files Browse the repository at this point in the history
Creates a unified ConsumerPact interface for consumer tests
and provider verification.

It also supports plugins.

This means you can create messages and HTTP interactions in the same
test through the same entrypoint, and conversely verify interactions
for multiple interaction types, including custom transports.

NOTE: this feature is currently in beta and is only usable
behind a feature toggle. It can be enabled by setting the
environment variable ENABLE_FEATURE_V4 to any value.
  • Loading branch information
mefellows committed Nov 10, 2022
1 parent 688a124 commit 7f87896
Show file tree
Hide file tree
Showing 25 changed files with 2,899 additions and 4,273 deletions.
6 changes: 5 additions & 1 deletion .eslintrc.json
Expand Up @@ -31,7 +31,11 @@
"import/prefer-default-export": "off",
"no-underscore-dangle": ["error", { "allow": ["__pactMessageMetadata"] }],
"class-methods-use-this": "off",
"no-use-before-define": "off"
"no-use-before-define": "off",
"no-empty-function": [
"error",
{ "allow": ["constructors"] }
]
},
"overrides": [
{
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/build-and-test.yml
Expand Up @@ -8,6 +8,8 @@ on:
env:
GIT_COMMIT: ${{ github.sha }}
GIT_REF: ${{ github.ref }}
ENABLE_FEATURE_V4: true
LOG_LEVEL: info

jobs:
build-and-test-ubuntu:
Expand Down Expand Up @@ -52,7 +54,7 @@ jobs:
env:
NODE_VERSION: ${{ matrix.node-version }}

# Failures not reproducible locally on an M1 Mac
# Failures not reproducible locally on an M1 Mac
# build-and-test-macos:
# runs-on: macos-latest
# strategy:
Expand Down
5,446 changes: 1,242 additions & 4,204 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -17,7 +17,7 @@
"postdist": "npm test",
"predist": "npm run clean && npm run format:check && npm run lint",
"release": "standard-version",
"test": "nyc --check-coverage --reporter=html --reporter=text-summary mocha"
"test": "nyc --check-coverage --reporter=html --reporter=text-summary mocha -t 120000"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -94,7 +94,7 @@
]
},
"dependencies": {
"@pact-foundation/pact-core": "^13.7.8",
"@pact-foundation/pact-core": "^13.12.0",
"@types/bluebird": "^3.5.20",
"@types/express": "^4.17.11",
"axios": "^0.27.2",
Expand Down
5 changes: 2 additions & 3 deletions src/dsl/matchers.spec.ts
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-unused-vars,no-unused-vars */
import { expect } from 'chai';
import {
boolean,
Expand Down Expand Up @@ -61,7 +62,6 @@ describe('Matcher', () => {
},
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars
const a: AnyTemplate = like(template);
});
});
Expand All @@ -77,13 +77,11 @@ describe('Matcher', () => {
},
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars
const a: AnyTemplate = like(template);
});
});

it('compiles nested likes', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars
const a: AnyTemplate = like({
someArray: ['one', 'two'],
someNumber: like(1),
Expand Down Expand Up @@ -200,6 +198,7 @@ describe('Matcher', () => {
describe('when an invalid value is provided', () => {
it('throws an Error', () => {
expect(createTheValue(undefined)).to.throw(Error);
// eslint-disable-next-line no-empty-function
expect(createTheValue(() => {})).to.throw(Error);
});
});
Expand Down
108 changes: 108 additions & 0 deletions src/dsl/verifier/proxy/messages.ts
@@ -0,0 +1,108 @@
import logger from '../../../common/logger';
import {
MessageDescriptor,
MessageFromProviderWithMetadata,
MessageProvider,
} from '../../message';
import express from 'express';
import bodyParser from 'body-parser';
import { encode as encodeBase64 } from 'js-base64';
import { ProxyOptions } from './types';

// Find a provider message handler, and invoke it
export const findMessageHandler = (
message: MessageDescriptor,
config: ProxyOptions
): Promise<MessageProvider> => {
const handler = config.messageProviders
? config.messageProviders[message.description]
: undefined;

if (!handler) {
logger.error(`no handler found for message ${message.description}`);

return Promise.reject(
new Error(
`No handler found for message "${message.description}".
Check your "handlers" configuration`
)
);
}

return Promise.resolve(handler);
};

// Get the Express app that will run on the HTTP Proxy
export const setupMessageProxyApplication = (
config: ProxyOptions
): express.Express => {
const app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use((_, res, next) => {
// TODO: this seems to override the metadata for content-type
res.header('Content-Type', 'application/json; charset=utf-8');
next();
});

// Proxy server will respond to Verifier process
app.all('/*', createProxyMessageHandler(config));

return app;
};

// Get the API handler for the verification CLI process to invoke on POST /*
export const createProxyMessageHandler = (
config: ProxyOptions
): ((req: express.Request, res: express.Response) => void) => {
return (req, res) => {
const message: MessageDescriptor = req.body;

// Invoke the handler, and return the JSON response body
// wrapped in a Message
findMessageHandler(message, config)
.then((handler) => handler(message))
.then((messageFromHandler) => {
if (hasMetadata(messageFromHandler)) {
const metadata = encodeBase64(
JSON.stringify(messageFromHandler.__pactMessageMetadata)
);
res.header('Pact-Message-Metadata', metadata);
res.header('PACT_MESSAGE_METADATA', metadata);

return res.json(messageFromHandler.message);
}
return res.json(messageFromHandler);
})
.catch((e) => res.status(500).send(e));
};
};

// // Get the Proxy we'll pass to the CLI for verification
// export const setupProxyServer = (
// app: (request: http.IncomingMessage, response: http.ServerResponse) => void
// ): http.Server => http.createServer(app).listen();

const hasMetadata = (
o: unknown | MessageFromProviderWithMetadata
): o is MessageFromProviderWithMetadata =>
Boolean((o as MessageFromProviderWithMetadata).__pactMessageMetadata);

export const providerWithMetadata =
(
provider: MessageProvider,
metadata: Record<string, string>
): MessageProvider =>
(descriptor: MessageDescriptor) =>
Promise.resolve(provider(descriptor)).then((message) =>
hasMetadata(message)
? {
__pactMessageMetadata: {
...message.__pactMessageMetadata,
...metadata,
},
message,
}
: { __pactMessageMetadata: metadata, message }
);
22 changes: 19 additions & 3 deletions src/dsl/verifier/proxy/proxy.ts
Expand Up @@ -9,6 +9,7 @@ import { createProxyStateHandler } from './stateHandler/stateHandler';
import { registerAfterHook, registerBeforeHook } from './hooks';
import { createRequestTracer, createResponseTracer } from './tracer';
import { parseBody } from './parseBody';
import { createProxyMessageHandler } from './messages';

// Listens for the server start event
export const waitForServerReady = (server: http.Server): Promise<http.Server> =>
Expand All @@ -22,7 +23,8 @@ export const waitForServerReady = (server: http.Server): Promise<http.Server> =>
// Get the Proxy we'll pass to the CLI for verification
export const createProxy = (
config: ProxyOptions,
stateSetupPath: string
stateSetupPath: string,
messageTransportPath: string
): http.Server => {
const app = express();
const proxy = new HttpProxy();
Expand Down Expand Up @@ -59,18 +61,32 @@ export const createProxy = (
// Setup provider state handler
app.post(stateSetupPath, createProxyStateHandler(config));

// Register message handler and transport
// TODO: ensure proxy does not interfere with this
app.post(messageTransportPath, createProxyMessageHandler(config));

// Proxy server will respond to Verifier process
app.all('/*', (req, res) => {
logger.debug(`Proxying ${req.method}: ${req.path}`);

proxy.web(req, res, {
changeOrigin: config.changeOrigin === true,
secure: config.validateSSL === true,
target: config.providerBaseUrl,
target: config.providerBaseUrl || defaultBaseURL(),
});
});

proxy.on('proxyReq', (proxyReq, req) => parseBody(proxyReq, req));

return http.createServer(app).listen();
// TODO: node is now using ipv6 as a default. This should be customised
return http
.createServer(app)
.listen(undefined, config.proxyHost || '127.0.0.1');
};

// A base URL is always needed for the proxy, even
// if there are no targets to proxy (e.g. in the case
// of message pact
const defaultBaseURL = () => {
return 'http://127.0.0.1/';
};
1 change: 1 addition & 0 deletions src/dsl/verifier/proxy/stateHandler/stateHandler.spec.ts
Expand Up @@ -18,6 +18,7 @@ describe('#createProxyStateHandler', () => {
status: (status: number) => {
res = status;
return {
// eslint-disable-next-line no-empty-function
send: () => {},
};
},
Expand Down
4 changes: 3 additions & 1 deletion src/dsl/verifier/proxy/types.ts
@@ -1,6 +1,7 @@
import * as express from 'express';
import { LogLevel } from '../../options';
import { JsonMap, AnyJson } from '../../../common/jsonTypes';
import { MessageProviders } from 'dsl/message';

export type Hook = () => Promise<unknown>;

Expand Down Expand Up @@ -46,10 +47,11 @@ export interface ProxyOptions {
logLevel?: LogLevel;
requestFilter?: express.RequestHandler;
stateHandlers?: StateHandlers;
messageProviders?: MessageProviders;
beforeEach?: Hook;
afterEach?: Hook;
validateSSL?: boolean;
changeOrigin?: boolean;
providerBaseUrl: string;
providerBaseUrl?: string;
proxyHost?: string;
}
15 changes: 14 additions & 1 deletion src/dsl/verifier/types.ts
@@ -1,5 +1,18 @@
import { VerifierOptions as PactCoreVerifierOptions } from '@pact-foundation/pact-core';
import { MessageProviderOptions } from 'dsl/options';

import { ProxyOptions } from './proxy/types';

export type VerifierOptions = PactCoreVerifierOptions & ProxyOptions;
type ExcludedPactNodeVerifierKeys = Exclude<
keyof PactCoreVerifierOptions,
'providerBaseUrl'
>;

export type PactNodeVerificationExcludedOptions = Pick<
PactCoreVerifierOptions,
ExcludedPactNodeVerifierKeys
>;

export type VerifierOptions = PactNodeVerificationExcludedOptions &
ProxyOptions &
Partial<MessageProviderOptions>;
34 changes: 31 additions & 3 deletions src/dsl/verifier/verifier.ts
Expand Up @@ -3,6 +3,7 @@
* @module ProviderVerifier
*/
import serviceFactory from '@pact-foundation/pact-core';
import { VerifierOptions as PactCoreVerifierOptions } from '@pact-foundation/pact-core';
import { omit, isEmpty } from 'lodash';
import * as http from 'http';
import * as url from 'url';
Expand All @@ -15,10 +16,12 @@ import { createProxy, waitForServerReady } from './proxy';
import { VerifierOptions } from './types';

export class Verifier {
private address = 'http://localhost';
private address = 'http://127.0.0.1';

private stateSetupPath = '/_pactSetup';

private messageTransportPath = '/_messages';

private config: VerifierOptions;

private deprecatedFields: string[] = ['providerStatesSetupUrl'];
Expand Down Expand Up @@ -57,6 +60,16 @@ export class Verifier {
);
}
}

if (
!this.config.providerBaseUrl &&
!this.config.messageProviders &&
!this?.config?.transports
) {
throw new ConfigurationError(
"'providerBaseUrl' is mandatory if no 'messageProviders' or 'transports' given"
);
}
}

/**
Expand All @@ -74,7 +87,11 @@ export class Verifier {
}

// Start the verification CLI proxy server
const server = createProxy(this.config, this.stateSetupPath);
const server = createProxy(
this.config,
this.stateSetupPath,
this.messageTransportPath
);
logger.trace(`proxy created, waiting for startup`);

// Run the verification once the proxy server is available
Expand All @@ -99,19 +116,30 @@ export class Verifier {
// Run the Verification CLI process
private runProviderVerification() {
return (server: http.Server) => {
const opts = {
const opts: PactCoreVerifierOptions = {
providerStatesSetupUrl: `${this.address}:${server.address().port}${
this.stateSetupPath
}`,
...omit(this.config, 'handlers'),
providerBaseUrl: `${this.address}:${server.address().port}`,
transports: this.config.transports?.concat([
{
port: server.address().port,
path: this.messageTransportPath,
protocol: 'message',
},
]),
};
logger.trace(`Verifying pacts with: ${JSON.stringify(opts)}`);
return serviceFactory.verifyPacts(opts);
};
}

private isLocalVerification() {
if (!this.config.providerBaseUrl) {
return true;
}

const u = new url.URL(this.config.providerBaseUrl);
return (
localAddresses.includes(u.host) || localAddresses.includes(u.hostname)
Expand Down
4 changes: 2 additions & 2 deletions src/messageConsumerPact.ts
Expand Up @@ -4,7 +4,7 @@

import { isEmpty } from 'lodash';
import serviceFactory, {
ConsumerMessage,
AsynchronousMessage,
makeConsumerAsyncMessagePact,
ConsumerMessagePact,
} from '@pact-foundation/pact-core';
Expand Down Expand Up @@ -48,7 +48,7 @@ export class MessageConsumerPact {

private pact: ConsumerMessagePact;

private message: ConsumerMessage;
private message: AsynchronousMessage;

constructor(private config: MessageConsumerOptions) {
this.pact = makeConsumerAsyncMessagePact(
Expand Down

0 comments on commit 7f87896

Please sign in to comment.